From e4ea51065b6915404ed592928567c9f67f88a18d Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 17:06:23 +0300 Subject: [PATCH 001/675] refactor: move binden into packages/binden Co-Authored-By: Claude Opus 5.5 --- .commitlintrc.json => packages/binden/.commitlintrc.json | 0 .eslintignore => packages/binden/.eslintignore | 0 {.github => packages/binden/.github}/ISSUE_TEMPLATE/bug_report.md | 0 .../binden/.github}/ISSUE_TEMPLATE/feature_request.md | 0 .../.github}/PULL_REQUEST_TEMPLATE/PULL_REQUEST_TEMPLATE.md | 0 {.github => packages/binden/.github}/dependabot.yml | 0 {.github => packages/binden/.github}/funding.yml | 0 {.github => packages/binden/.github}/workflows/ci.yml | 0 .gitignore => packages/binden/.gitignore | 0 {.husky => packages/binden/.husky}/.gitignore | 0 {.husky => packages/binden/.husky}/commit-msg | 0 {.husky => packages/binden/.husky}/pre-commit | 0 .lintstagedrc.json => packages/binden/.lintstagedrc.json | 0 .mocharc.json => packages/binden/.mocharc.json | 0 .npmignore => packages/binden/.npmignore | 0 .prettierignore => packages/binden/.prettierignore | 0 .releaserc.json => packages/binden/.releaserc.json | 0 CHANGELOG.md => packages/binden/CHANGELOG.md | 0 CODE_OF_CONDUCT.md => packages/binden/CODE_OF_CONDUCT.md | 0 LICENSE => packages/binden/LICENSE | 0 README.md => packages/binden/README.md | 0 {docs => packages/binden/docs}/.nojekyll | 0 {docs => packages/binden/docs}/assets/hierarchy.js | 0 {docs => packages/binden/docs}/assets/highlight.css | 0 {docs => packages/binden/docs}/assets/icons.js | 0 {docs => packages/binden/docs}/assets/icons.svg | 0 {docs => packages/binden/docs}/assets/main.js | 0 {docs => packages/binden/docs}/assets/navigation.js | 0 {docs => packages/binden/docs}/assets/search.js | 0 {docs => packages/binden/docs}/assets/style.css | 0 {docs => packages/binden/docs}/classes/AcceptEncoding.html | 0 {docs => packages/binden/docs}/classes/Authorization.html | 0 {docs => packages/binden/docs}/classes/Binden.html | 0 {docs => packages/binden/docs}/classes/BindenError.html | 0 {docs => packages/binden/docs}/classes/BindenRequest.html | 0 {docs => packages/binden/docs}/classes/BindenResponse.html | 0 {docs => packages/binden/docs}/classes/ContentEncoding.html | 0 {docs => packages/binden/docs}/classes/ContentRange.html | 0 {docs => packages/binden/docs}/classes/ContentType.html | 0 {docs => packages/binden/docs}/classes/Context.html | 0 {docs => packages/binden/docs}/classes/Cookie.html | 0 {docs => packages/binden/docs}/classes/Forwarded.html | 0 {docs => packages/binden/docs}/classes/IfModifiedSince.html | 0 {docs => packages/binden/docs}/classes/Middleware.html | 0 {docs => packages/binden/docs}/classes/Range.html | 0 {docs => packages/binden/docs}/classes/Router.html | 0 {docs => packages/binden/docs}/hierarchy.html | 0 {docs => packages/binden/docs}/index.html | 0 {docs => packages/binden/docs}/interfaces/IAcceptEncoding.html | 0 {docs => packages/binden/docs}/interfaces/IAuthorization.html | 0 {docs => packages/binden/docs}/interfaces/IBaseContext.html | 0 .../binden/docs}/interfaces/IBindenErrorOptions.html | 0 {docs => packages/binden/docs}/interfaces/IBindenOptions.html | 0 {docs => packages/binden/docs}/interfaces/IContentEncoding.html | 0 {docs => packages/binden/docs}/interfaces/IContentRange.html | 0 {docs => packages/binden/docs}/interfaces/IContentType.html | 0 {docs => packages/binden/docs}/interfaces/ICookie.html | 0 {docs => packages/binden/docs}/interfaces/IForwarded.html | 0 {docs => packages/binden/docs}/interfaces/IIfModifiedSince.html | 0 {docs => packages/binden/docs}/interfaces/IMiddlewareParams.html | 0 {docs => packages/binden/docs}/interfaces/IRange.html | 0 {docs => packages/binden/docs}/interfaces/IRouterOptions.html | 0 {docs => packages/binden/docs}/modules.html | 0 {docs => packages/binden/docs}/types/IAcceptEncodings.html | 0 {docs => packages/binden/docs}/types/IAuthenticationType.html | 0 {docs => packages/binden/docs}/types/IEncodings.html | 0 {docs => packages/binden/docs}/types/IFunctionMiddleware.html | 0 {docs => packages/binden/docs}/types/IHeaders.html | 0 {docs => packages/binden/docs}/types/IHeadersValue.html | 0 {docs => packages/binden/docs}/types/IMiddleware.html | 0 {docs => packages/binden/docs}/types/IProtocol.html | 0 {docs => packages/binden/docs}/types/IStack.html | 0 {docs => packages/binden/docs}/types/IStackItem.html | 0 {docs => packages/binden/docs}/types/IUnit.html | 0 {docs => packages/binden/docs}/variables/DefaultErrorCode.html | 0 {docs => packages/binden/docs}/variables/ct_form.html | 0 {docs => packages/binden/docs}/variables/ct_html.html | 0 {docs => packages/binden/docs}/variables/ct_json.html | 0 {docs => packages/binden/docs}/variables/ct_text.html | 0 eslint.config.js => packages/binden/eslint.config.js | 0 index.ts => packages/binden/index.ts | 0 package-lock.json => packages/binden/package-lock.json | 0 package.json => packages/binden/package.json | 0 {src => packages/binden/src}/binden.ts | 0 {src => packages/binden/src}/context.ts | 0 {src => packages/binden/src}/error.ts | 0 {src => packages/binden/src}/headers/accept-encoding.ts | 0 {src => packages/binden/src}/headers/authorization.ts | 0 {src => packages/binden/src}/headers/content-encoding.ts | 0 {src => packages/binden/src}/headers/content-range.ts | 0 {src => packages/binden/src}/headers/content-type.ts | 0 {src => packages/binden/src}/headers/cookie.ts | 0 {src => packages/binden/src}/headers/forwarded.ts | 0 {src => packages/binden/src}/headers/if-modified-since.ts | 0 {src => packages/binden/src}/headers/range.ts | 0 {src => packages/binden/src}/logger.ts | 0 {src => packages/binden/src}/middleware.ts | 0 {src => packages/binden/src}/request.ts | 0 {src => packages/binden/src}/response.ts | 0 {src => packages/binden/src}/router.ts | 0 {test => packages/binden/test}/binden.test.ts | 0 {test => packages/binden/test}/cert/cert.pem | 0 {test => packages/binden/test}/cert/key.pem | 0 {test => packages/binden/test}/context.test.ts | 0 {test => packages/binden/test}/error.test.ts | 0 {test => packages/binden/test}/headers/accept-encoding.test.ts | 0 {test => packages/binden/test}/headers/authorization.test.ts | 0 {test => packages/binden/test}/headers/content-encoding.test.ts | 0 {test => packages/binden/test}/headers/content-range.test.ts | 0 {test => packages/binden/test}/headers/content-type.test.ts | 0 {test => packages/binden/test}/headers/cookie.test.ts | 0 {test => packages/binden/test}/headers/forwarded.test.ts | 0 {test => packages/binden/test}/headers/if-modified-since.test.ts | 0 {test => packages/binden/test}/headers/range.test.ts | 0 {test => packages/binden/test}/middleware.test.ts | 0 {test => packages/binden/test}/request.test.ts | 0 {test => packages/binden/test}/response.test.ts | 0 {test => packages/binden/test}/router.test.ts | 0 tsconfig.json => packages/binden/tsconfig.json | 0 typedoc.json => packages/binden/typedoc.json | 0 120 files changed, 0 insertions(+), 0 deletions(-) rename .commitlintrc.json => packages/binden/.commitlintrc.json (100%) rename .eslintignore => packages/binden/.eslintignore (100%) rename {.github => packages/binden/.github}/ISSUE_TEMPLATE/bug_report.md (100%) rename {.github => packages/binden/.github}/ISSUE_TEMPLATE/feature_request.md (100%) rename {.github => packages/binden/.github}/PULL_REQUEST_TEMPLATE/PULL_REQUEST_TEMPLATE.md (100%) rename {.github => packages/binden/.github}/dependabot.yml (100%) rename {.github => packages/binden/.github}/funding.yml (100%) rename {.github => packages/binden/.github}/workflows/ci.yml (100%) rename .gitignore => packages/binden/.gitignore (100%) rename {.husky => packages/binden/.husky}/.gitignore (100%) rename {.husky => packages/binden/.husky}/commit-msg (100%) rename {.husky => packages/binden/.husky}/pre-commit (100%) rename .lintstagedrc.json => packages/binden/.lintstagedrc.json (100%) rename .mocharc.json => packages/binden/.mocharc.json (100%) rename .npmignore => packages/binden/.npmignore (100%) rename .prettierignore => packages/binden/.prettierignore (100%) rename .releaserc.json => packages/binden/.releaserc.json (100%) rename CHANGELOG.md => packages/binden/CHANGELOG.md (100%) rename CODE_OF_CONDUCT.md => packages/binden/CODE_OF_CONDUCT.md (100%) rename LICENSE => packages/binden/LICENSE (100%) rename README.md => packages/binden/README.md (100%) rename {docs => packages/binden/docs}/.nojekyll (100%) rename {docs => packages/binden/docs}/assets/hierarchy.js (100%) rename {docs => packages/binden/docs}/assets/highlight.css (100%) rename {docs => packages/binden/docs}/assets/icons.js (100%) rename {docs => packages/binden/docs}/assets/icons.svg (100%) rename {docs => packages/binden/docs}/assets/main.js (100%) rename {docs => packages/binden/docs}/assets/navigation.js (100%) rename {docs => packages/binden/docs}/assets/search.js (100%) rename {docs => packages/binden/docs}/assets/style.css (100%) rename {docs => packages/binden/docs}/classes/AcceptEncoding.html (100%) rename {docs => packages/binden/docs}/classes/Authorization.html (100%) rename {docs => packages/binden/docs}/classes/Binden.html (100%) rename {docs => packages/binden/docs}/classes/BindenError.html (100%) rename {docs => packages/binden/docs}/classes/BindenRequest.html (100%) rename {docs => packages/binden/docs}/classes/BindenResponse.html (100%) rename {docs => packages/binden/docs}/classes/ContentEncoding.html (100%) rename {docs => packages/binden/docs}/classes/ContentRange.html (100%) rename {docs => packages/binden/docs}/classes/ContentType.html (100%) rename {docs => packages/binden/docs}/classes/Context.html (100%) rename {docs => packages/binden/docs}/classes/Cookie.html (100%) rename {docs => packages/binden/docs}/classes/Forwarded.html (100%) rename {docs => packages/binden/docs}/classes/IfModifiedSince.html (100%) rename {docs => packages/binden/docs}/classes/Middleware.html (100%) rename {docs => packages/binden/docs}/classes/Range.html (100%) rename {docs => packages/binden/docs}/classes/Router.html (100%) rename {docs => packages/binden/docs}/hierarchy.html (100%) rename {docs => packages/binden/docs}/index.html (100%) rename {docs => packages/binden/docs}/interfaces/IAcceptEncoding.html (100%) rename {docs => packages/binden/docs}/interfaces/IAuthorization.html (100%) rename {docs => packages/binden/docs}/interfaces/IBaseContext.html (100%) rename {docs => packages/binden/docs}/interfaces/IBindenErrorOptions.html (100%) rename {docs => packages/binden/docs}/interfaces/IBindenOptions.html (100%) rename {docs => packages/binden/docs}/interfaces/IContentEncoding.html (100%) rename {docs => packages/binden/docs}/interfaces/IContentRange.html (100%) rename {docs => packages/binden/docs}/interfaces/IContentType.html (100%) rename {docs => packages/binden/docs}/interfaces/ICookie.html (100%) rename {docs => packages/binden/docs}/interfaces/IForwarded.html (100%) rename {docs => packages/binden/docs}/interfaces/IIfModifiedSince.html (100%) rename {docs => packages/binden/docs}/interfaces/IMiddlewareParams.html (100%) rename {docs => packages/binden/docs}/interfaces/IRange.html (100%) rename {docs => packages/binden/docs}/interfaces/IRouterOptions.html (100%) rename {docs => packages/binden/docs}/modules.html (100%) rename {docs => packages/binden/docs}/types/IAcceptEncodings.html (100%) rename {docs => packages/binden/docs}/types/IAuthenticationType.html (100%) rename {docs => packages/binden/docs}/types/IEncodings.html (100%) rename {docs => packages/binden/docs}/types/IFunctionMiddleware.html (100%) rename {docs => packages/binden/docs}/types/IHeaders.html (100%) rename {docs => packages/binden/docs}/types/IHeadersValue.html (100%) rename {docs => packages/binden/docs}/types/IMiddleware.html (100%) rename {docs => packages/binden/docs}/types/IProtocol.html (100%) rename {docs => packages/binden/docs}/types/IStack.html (100%) rename {docs => packages/binden/docs}/types/IStackItem.html (100%) rename {docs => packages/binden/docs}/types/IUnit.html (100%) rename {docs => packages/binden/docs}/variables/DefaultErrorCode.html (100%) rename {docs => packages/binden/docs}/variables/ct_form.html (100%) rename {docs => packages/binden/docs}/variables/ct_html.html (100%) rename {docs => packages/binden/docs}/variables/ct_json.html (100%) rename {docs => packages/binden/docs}/variables/ct_text.html (100%) rename eslint.config.js => packages/binden/eslint.config.js (100%) rename index.ts => packages/binden/index.ts (100%) rename package-lock.json => packages/binden/package-lock.json (100%) rename package.json => packages/binden/package.json (100%) rename {src => packages/binden/src}/binden.ts (100%) rename {src => packages/binden/src}/context.ts (100%) rename {src => packages/binden/src}/error.ts (100%) rename {src => packages/binden/src}/headers/accept-encoding.ts (100%) rename {src => packages/binden/src}/headers/authorization.ts (100%) rename {src => packages/binden/src}/headers/content-encoding.ts (100%) rename {src => packages/binden/src}/headers/content-range.ts (100%) rename {src => packages/binden/src}/headers/content-type.ts (100%) rename {src => packages/binden/src}/headers/cookie.ts (100%) rename {src => packages/binden/src}/headers/forwarded.ts (100%) rename {src => packages/binden/src}/headers/if-modified-since.ts (100%) rename {src => packages/binden/src}/headers/range.ts (100%) rename {src => packages/binden/src}/logger.ts (100%) rename {src => packages/binden/src}/middleware.ts (100%) rename {src => packages/binden/src}/request.ts (100%) rename {src => packages/binden/src}/response.ts (100%) rename {src => packages/binden/src}/router.ts (100%) rename {test => packages/binden/test}/binden.test.ts (100%) rename {test => packages/binden/test}/cert/cert.pem (100%) rename {test => packages/binden/test}/cert/key.pem (100%) rename {test => packages/binden/test}/context.test.ts (100%) rename {test => packages/binden/test}/error.test.ts (100%) rename {test => packages/binden/test}/headers/accept-encoding.test.ts (100%) rename {test => packages/binden/test}/headers/authorization.test.ts (100%) rename {test => packages/binden/test}/headers/content-encoding.test.ts (100%) rename {test => packages/binden/test}/headers/content-range.test.ts (100%) rename {test => packages/binden/test}/headers/content-type.test.ts (100%) rename {test => packages/binden/test}/headers/cookie.test.ts (100%) rename {test => packages/binden/test}/headers/forwarded.test.ts (100%) rename {test => packages/binden/test}/headers/if-modified-since.test.ts (100%) rename {test => packages/binden/test}/headers/range.test.ts (100%) rename {test => packages/binden/test}/middleware.test.ts (100%) rename {test => packages/binden/test}/request.test.ts (100%) rename {test => packages/binden/test}/response.test.ts (100%) rename {test => packages/binden/test}/router.test.ts (100%) rename tsconfig.json => packages/binden/tsconfig.json (100%) rename typedoc.json => packages/binden/typedoc.json (100%) diff --git a/.commitlintrc.json b/packages/binden/.commitlintrc.json similarity index 100% rename from .commitlintrc.json rename to packages/binden/.commitlintrc.json diff --git a/.eslintignore b/packages/binden/.eslintignore similarity index 100% rename from .eslintignore rename to packages/binden/.eslintignore diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/packages/binden/.github/ISSUE_TEMPLATE/bug_report.md similarity index 100% rename from .github/ISSUE_TEMPLATE/bug_report.md rename to packages/binden/.github/ISSUE_TEMPLATE/bug_report.md diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/packages/binden/.github/ISSUE_TEMPLATE/feature_request.md similarity index 100% rename from .github/ISSUE_TEMPLATE/feature_request.md rename to packages/binden/.github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/PULL_REQUEST_TEMPLATE/PULL_REQUEST_TEMPLATE.md b/packages/binden/.github/PULL_REQUEST_TEMPLATE/PULL_REQUEST_TEMPLATE.md similarity index 100% rename from .github/PULL_REQUEST_TEMPLATE/PULL_REQUEST_TEMPLATE.md rename to packages/binden/.github/PULL_REQUEST_TEMPLATE/PULL_REQUEST_TEMPLATE.md diff --git a/.github/dependabot.yml b/packages/binden/.github/dependabot.yml similarity index 100% rename from .github/dependabot.yml rename to packages/binden/.github/dependabot.yml diff --git a/.github/funding.yml b/packages/binden/.github/funding.yml similarity index 100% rename from .github/funding.yml rename to packages/binden/.github/funding.yml diff --git a/.github/workflows/ci.yml b/packages/binden/.github/workflows/ci.yml similarity index 100% rename from .github/workflows/ci.yml rename to packages/binden/.github/workflows/ci.yml diff --git a/.gitignore b/packages/binden/.gitignore similarity index 100% rename from .gitignore rename to packages/binden/.gitignore diff --git a/.husky/.gitignore b/packages/binden/.husky/.gitignore similarity index 100% rename from .husky/.gitignore rename to packages/binden/.husky/.gitignore diff --git a/.husky/commit-msg b/packages/binden/.husky/commit-msg similarity index 100% rename from .husky/commit-msg rename to packages/binden/.husky/commit-msg diff --git a/.husky/pre-commit b/packages/binden/.husky/pre-commit similarity index 100% rename from .husky/pre-commit rename to packages/binden/.husky/pre-commit diff --git a/.lintstagedrc.json b/packages/binden/.lintstagedrc.json similarity index 100% rename from .lintstagedrc.json rename to packages/binden/.lintstagedrc.json diff --git a/.mocharc.json b/packages/binden/.mocharc.json similarity index 100% rename from .mocharc.json rename to packages/binden/.mocharc.json diff --git a/.npmignore b/packages/binden/.npmignore similarity index 100% rename from .npmignore rename to packages/binden/.npmignore diff --git a/.prettierignore b/packages/binden/.prettierignore similarity index 100% rename from .prettierignore rename to packages/binden/.prettierignore diff --git a/.releaserc.json b/packages/binden/.releaserc.json similarity index 100% rename from .releaserc.json rename to packages/binden/.releaserc.json diff --git a/CHANGELOG.md b/packages/binden/CHANGELOG.md similarity index 100% rename from CHANGELOG.md rename to packages/binden/CHANGELOG.md diff --git a/CODE_OF_CONDUCT.md b/packages/binden/CODE_OF_CONDUCT.md similarity index 100% rename from CODE_OF_CONDUCT.md rename to packages/binden/CODE_OF_CONDUCT.md diff --git a/LICENSE b/packages/binden/LICENSE similarity index 100% rename from LICENSE rename to packages/binden/LICENSE diff --git a/README.md b/packages/binden/README.md similarity index 100% rename from README.md rename to packages/binden/README.md diff --git a/docs/.nojekyll b/packages/binden/docs/.nojekyll similarity index 100% rename from docs/.nojekyll rename to packages/binden/docs/.nojekyll diff --git a/docs/assets/hierarchy.js b/packages/binden/docs/assets/hierarchy.js similarity index 100% rename from docs/assets/hierarchy.js rename to packages/binden/docs/assets/hierarchy.js diff --git a/docs/assets/highlight.css b/packages/binden/docs/assets/highlight.css similarity index 100% rename from docs/assets/highlight.css rename to packages/binden/docs/assets/highlight.css diff --git a/docs/assets/icons.js b/packages/binden/docs/assets/icons.js similarity index 100% rename from docs/assets/icons.js rename to packages/binden/docs/assets/icons.js diff --git a/docs/assets/icons.svg b/packages/binden/docs/assets/icons.svg similarity index 100% rename from docs/assets/icons.svg rename to packages/binden/docs/assets/icons.svg diff --git a/docs/assets/main.js b/packages/binden/docs/assets/main.js similarity index 100% rename from docs/assets/main.js rename to packages/binden/docs/assets/main.js diff --git a/docs/assets/navigation.js b/packages/binden/docs/assets/navigation.js similarity index 100% rename from docs/assets/navigation.js rename to packages/binden/docs/assets/navigation.js diff --git a/docs/assets/search.js b/packages/binden/docs/assets/search.js similarity index 100% rename from docs/assets/search.js rename to packages/binden/docs/assets/search.js diff --git a/docs/assets/style.css b/packages/binden/docs/assets/style.css similarity index 100% rename from docs/assets/style.css rename to packages/binden/docs/assets/style.css diff --git a/docs/classes/AcceptEncoding.html b/packages/binden/docs/classes/AcceptEncoding.html similarity index 100% rename from docs/classes/AcceptEncoding.html rename to packages/binden/docs/classes/AcceptEncoding.html diff --git a/docs/classes/Authorization.html b/packages/binden/docs/classes/Authorization.html similarity index 100% rename from docs/classes/Authorization.html rename to packages/binden/docs/classes/Authorization.html diff --git a/docs/classes/Binden.html b/packages/binden/docs/classes/Binden.html similarity index 100% rename from docs/classes/Binden.html rename to packages/binden/docs/classes/Binden.html diff --git a/docs/classes/BindenError.html b/packages/binden/docs/classes/BindenError.html similarity index 100% rename from docs/classes/BindenError.html rename to packages/binden/docs/classes/BindenError.html diff --git a/docs/classes/BindenRequest.html b/packages/binden/docs/classes/BindenRequest.html similarity index 100% rename from docs/classes/BindenRequest.html rename to packages/binden/docs/classes/BindenRequest.html diff --git a/docs/classes/BindenResponse.html b/packages/binden/docs/classes/BindenResponse.html similarity index 100% rename from docs/classes/BindenResponse.html rename to packages/binden/docs/classes/BindenResponse.html diff --git a/docs/classes/ContentEncoding.html b/packages/binden/docs/classes/ContentEncoding.html similarity index 100% rename from docs/classes/ContentEncoding.html rename to packages/binden/docs/classes/ContentEncoding.html diff --git a/docs/classes/ContentRange.html b/packages/binden/docs/classes/ContentRange.html similarity index 100% rename from docs/classes/ContentRange.html rename to packages/binden/docs/classes/ContentRange.html diff --git a/docs/classes/ContentType.html b/packages/binden/docs/classes/ContentType.html similarity index 100% rename from docs/classes/ContentType.html rename to packages/binden/docs/classes/ContentType.html diff --git a/docs/classes/Context.html b/packages/binden/docs/classes/Context.html similarity index 100% rename from docs/classes/Context.html rename to packages/binden/docs/classes/Context.html diff --git a/docs/classes/Cookie.html b/packages/binden/docs/classes/Cookie.html similarity index 100% rename from docs/classes/Cookie.html rename to packages/binden/docs/classes/Cookie.html diff --git a/docs/classes/Forwarded.html b/packages/binden/docs/classes/Forwarded.html similarity index 100% rename from docs/classes/Forwarded.html rename to packages/binden/docs/classes/Forwarded.html diff --git a/docs/classes/IfModifiedSince.html b/packages/binden/docs/classes/IfModifiedSince.html similarity index 100% rename from docs/classes/IfModifiedSince.html rename to packages/binden/docs/classes/IfModifiedSince.html diff --git a/docs/classes/Middleware.html b/packages/binden/docs/classes/Middleware.html similarity index 100% rename from docs/classes/Middleware.html rename to packages/binden/docs/classes/Middleware.html diff --git a/docs/classes/Range.html b/packages/binden/docs/classes/Range.html similarity index 100% rename from docs/classes/Range.html rename to packages/binden/docs/classes/Range.html diff --git a/docs/classes/Router.html b/packages/binden/docs/classes/Router.html similarity index 100% rename from docs/classes/Router.html rename to packages/binden/docs/classes/Router.html diff --git a/docs/hierarchy.html b/packages/binden/docs/hierarchy.html similarity index 100% rename from docs/hierarchy.html rename to packages/binden/docs/hierarchy.html diff --git a/docs/index.html b/packages/binden/docs/index.html similarity index 100% rename from docs/index.html rename to packages/binden/docs/index.html diff --git a/docs/interfaces/IAcceptEncoding.html b/packages/binden/docs/interfaces/IAcceptEncoding.html similarity index 100% rename from docs/interfaces/IAcceptEncoding.html rename to packages/binden/docs/interfaces/IAcceptEncoding.html diff --git a/docs/interfaces/IAuthorization.html b/packages/binden/docs/interfaces/IAuthorization.html similarity index 100% rename from docs/interfaces/IAuthorization.html rename to packages/binden/docs/interfaces/IAuthorization.html diff --git a/docs/interfaces/IBaseContext.html b/packages/binden/docs/interfaces/IBaseContext.html similarity index 100% rename from docs/interfaces/IBaseContext.html rename to packages/binden/docs/interfaces/IBaseContext.html diff --git a/docs/interfaces/IBindenErrorOptions.html b/packages/binden/docs/interfaces/IBindenErrorOptions.html similarity index 100% rename from docs/interfaces/IBindenErrorOptions.html rename to packages/binden/docs/interfaces/IBindenErrorOptions.html diff --git a/docs/interfaces/IBindenOptions.html b/packages/binden/docs/interfaces/IBindenOptions.html similarity index 100% rename from docs/interfaces/IBindenOptions.html rename to packages/binden/docs/interfaces/IBindenOptions.html diff --git a/docs/interfaces/IContentEncoding.html b/packages/binden/docs/interfaces/IContentEncoding.html similarity index 100% rename from docs/interfaces/IContentEncoding.html rename to packages/binden/docs/interfaces/IContentEncoding.html diff --git a/docs/interfaces/IContentRange.html b/packages/binden/docs/interfaces/IContentRange.html similarity index 100% rename from docs/interfaces/IContentRange.html rename to packages/binden/docs/interfaces/IContentRange.html diff --git a/docs/interfaces/IContentType.html b/packages/binden/docs/interfaces/IContentType.html similarity index 100% rename from docs/interfaces/IContentType.html rename to packages/binden/docs/interfaces/IContentType.html diff --git a/docs/interfaces/ICookie.html b/packages/binden/docs/interfaces/ICookie.html similarity index 100% rename from docs/interfaces/ICookie.html rename to packages/binden/docs/interfaces/ICookie.html diff --git a/docs/interfaces/IForwarded.html b/packages/binden/docs/interfaces/IForwarded.html similarity index 100% rename from docs/interfaces/IForwarded.html rename to packages/binden/docs/interfaces/IForwarded.html diff --git a/docs/interfaces/IIfModifiedSince.html b/packages/binden/docs/interfaces/IIfModifiedSince.html similarity index 100% rename from docs/interfaces/IIfModifiedSince.html rename to packages/binden/docs/interfaces/IIfModifiedSince.html diff --git a/docs/interfaces/IMiddlewareParams.html b/packages/binden/docs/interfaces/IMiddlewareParams.html similarity index 100% rename from docs/interfaces/IMiddlewareParams.html rename to packages/binden/docs/interfaces/IMiddlewareParams.html diff --git a/docs/interfaces/IRange.html b/packages/binden/docs/interfaces/IRange.html similarity index 100% rename from docs/interfaces/IRange.html rename to packages/binden/docs/interfaces/IRange.html diff --git a/docs/interfaces/IRouterOptions.html b/packages/binden/docs/interfaces/IRouterOptions.html similarity index 100% rename from docs/interfaces/IRouterOptions.html rename to packages/binden/docs/interfaces/IRouterOptions.html diff --git a/docs/modules.html b/packages/binden/docs/modules.html similarity index 100% rename from docs/modules.html rename to packages/binden/docs/modules.html diff --git a/docs/types/IAcceptEncodings.html b/packages/binden/docs/types/IAcceptEncodings.html similarity index 100% rename from docs/types/IAcceptEncodings.html rename to packages/binden/docs/types/IAcceptEncodings.html diff --git a/docs/types/IAuthenticationType.html b/packages/binden/docs/types/IAuthenticationType.html similarity index 100% rename from docs/types/IAuthenticationType.html rename to packages/binden/docs/types/IAuthenticationType.html diff --git a/docs/types/IEncodings.html b/packages/binden/docs/types/IEncodings.html similarity index 100% rename from docs/types/IEncodings.html rename to packages/binden/docs/types/IEncodings.html diff --git a/docs/types/IFunctionMiddleware.html b/packages/binden/docs/types/IFunctionMiddleware.html similarity index 100% rename from docs/types/IFunctionMiddleware.html rename to packages/binden/docs/types/IFunctionMiddleware.html diff --git a/docs/types/IHeaders.html b/packages/binden/docs/types/IHeaders.html similarity index 100% rename from docs/types/IHeaders.html rename to packages/binden/docs/types/IHeaders.html diff --git a/docs/types/IHeadersValue.html b/packages/binden/docs/types/IHeadersValue.html similarity index 100% rename from docs/types/IHeadersValue.html rename to packages/binden/docs/types/IHeadersValue.html diff --git a/docs/types/IMiddleware.html b/packages/binden/docs/types/IMiddleware.html similarity index 100% rename from docs/types/IMiddleware.html rename to packages/binden/docs/types/IMiddleware.html diff --git a/docs/types/IProtocol.html b/packages/binden/docs/types/IProtocol.html similarity index 100% rename from docs/types/IProtocol.html rename to packages/binden/docs/types/IProtocol.html diff --git a/docs/types/IStack.html b/packages/binden/docs/types/IStack.html similarity index 100% rename from docs/types/IStack.html rename to packages/binden/docs/types/IStack.html diff --git a/docs/types/IStackItem.html b/packages/binden/docs/types/IStackItem.html similarity index 100% rename from docs/types/IStackItem.html rename to packages/binden/docs/types/IStackItem.html diff --git a/docs/types/IUnit.html b/packages/binden/docs/types/IUnit.html similarity index 100% rename from docs/types/IUnit.html rename to packages/binden/docs/types/IUnit.html diff --git a/docs/variables/DefaultErrorCode.html b/packages/binden/docs/variables/DefaultErrorCode.html similarity index 100% rename from docs/variables/DefaultErrorCode.html rename to packages/binden/docs/variables/DefaultErrorCode.html diff --git a/docs/variables/ct_form.html b/packages/binden/docs/variables/ct_form.html similarity index 100% rename from docs/variables/ct_form.html rename to packages/binden/docs/variables/ct_form.html diff --git a/docs/variables/ct_html.html b/packages/binden/docs/variables/ct_html.html similarity index 100% rename from docs/variables/ct_html.html rename to packages/binden/docs/variables/ct_html.html diff --git a/docs/variables/ct_json.html b/packages/binden/docs/variables/ct_json.html similarity index 100% rename from docs/variables/ct_json.html rename to packages/binden/docs/variables/ct_json.html diff --git a/docs/variables/ct_text.html b/packages/binden/docs/variables/ct_text.html similarity index 100% rename from docs/variables/ct_text.html rename to packages/binden/docs/variables/ct_text.html diff --git a/eslint.config.js b/packages/binden/eslint.config.js similarity index 100% rename from eslint.config.js rename to packages/binden/eslint.config.js diff --git a/index.ts b/packages/binden/index.ts similarity index 100% rename from index.ts rename to packages/binden/index.ts diff --git a/package-lock.json b/packages/binden/package-lock.json similarity index 100% rename from package-lock.json rename to packages/binden/package-lock.json diff --git a/package.json b/packages/binden/package.json similarity index 100% rename from package.json rename to packages/binden/package.json diff --git a/src/binden.ts b/packages/binden/src/binden.ts similarity index 100% rename from src/binden.ts rename to packages/binden/src/binden.ts diff --git a/src/context.ts b/packages/binden/src/context.ts similarity index 100% rename from src/context.ts rename to packages/binden/src/context.ts diff --git a/src/error.ts b/packages/binden/src/error.ts similarity index 100% rename from src/error.ts rename to packages/binden/src/error.ts diff --git a/src/headers/accept-encoding.ts b/packages/binden/src/headers/accept-encoding.ts similarity index 100% rename from src/headers/accept-encoding.ts rename to packages/binden/src/headers/accept-encoding.ts diff --git a/src/headers/authorization.ts b/packages/binden/src/headers/authorization.ts similarity index 100% rename from src/headers/authorization.ts rename to packages/binden/src/headers/authorization.ts diff --git a/src/headers/content-encoding.ts b/packages/binden/src/headers/content-encoding.ts similarity index 100% rename from src/headers/content-encoding.ts rename to packages/binden/src/headers/content-encoding.ts diff --git a/src/headers/content-range.ts b/packages/binden/src/headers/content-range.ts similarity index 100% rename from src/headers/content-range.ts rename to packages/binden/src/headers/content-range.ts diff --git a/src/headers/content-type.ts b/packages/binden/src/headers/content-type.ts similarity index 100% rename from src/headers/content-type.ts rename to packages/binden/src/headers/content-type.ts diff --git a/src/headers/cookie.ts b/packages/binden/src/headers/cookie.ts similarity index 100% rename from src/headers/cookie.ts rename to packages/binden/src/headers/cookie.ts diff --git a/src/headers/forwarded.ts b/packages/binden/src/headers/forwarded.ts similarity index 100% rename from src/headers/forwarded.ts rename to packages/binden/src/headers/forwarded.ts diff --git a/src/headers/if-modified-since.ts b/packages/binden/src/headers/if-modified-since.ts similarity index 100% rename from src/headers/if-modified-since.ts rename to packages/binden/src/headers/if-modified-since.ts diff --git a/src/headers/range.ts b/packages/binden/src/headers/range.ts similarity index 100% rename from src/headers/range.ts rename to packages/binden/src/headers/range.ts diff --git a/src/logger.ts b/packages/binden/src/logger.ts similarity index 100% rename from src/logger.ts rename to packages/binden/src/logger.ts diff --git a/src/middleware.ts b/packages/binden/src/middleware.ts similarity index 100% rename from src/middleware.ts rename to packages/binden/src/middleware.ts diff --git a/src/request.ts b/packages/binden/src/request.ts similarity index 100% rename from src/request.ts rename to packages/binden/src/request.ts diff --git a/src/response.ts b/packages/binden/src/response.ts similarity index 100% rename from src/response.ts rename to packages/binden/src/response.ts diff --git a/src/router.ts b/packages/binden/src/router.ts similarity index 100% rename from src/router.ts rename to packages/binden/src/router.ts diff --git a/test/binden.test.ts b/packages/binden/test/binden.test.ts similarity index 100% rename from test/binden.test.ts rename to packages/binden/test/binden.test.ts diff --git a/test/cert/cert.pem b/packages/binden/test/cert/cert.pem similarity index 100% rename from test/cert/cert.pem rename to packages/binden/test/cert/cert.pem diff --git a/test/cert/key.pem b/packages/binden/test/cert/key.pem similarity index 100% rename from test/cert/key.pem rename to packages/binden/test/cert/key.pem diff --git a/test/context.test.ts b/packages/binden/test/context.test.ts similarity index 100% rename from test/context.test.ts rename to packages/binden/test/context.test.ts diff --git a/test/error.test.ts b/packages/binden/test/error.test.ts similarity index 100% rename from test/error.test.ts rename to packages/binden/test/error.test.ts diff --git a/test/headers/accept-encoding.test.ts b/packages/binden/test/headers/accept-encoding.test.ts similarity index 100% rename from test/headers/accept-encoding.test.ts rename to packages/binden/test/headers/accept-encoding.test.ts diff --git a/test/headers/authorization.test.ts b/packages/binden/test/headers/authorization.test.ts similarity index 100% rename from test/headers/authorization.test.ts rename to packages/binden/test/headers/authorization.test.ts diff --git a/test/headers/content-encoding.test.ts b/packages/binden/test/headers/content-encoding.test.ts similarity index 100% rename from test/headers/content-encoding.test.ts rename to packages/binden/test/headers/content-encoding.test.ts diff --git a/test/headers/content-range.test.ts b/packages/binden/test/headers/content-range.test.ts similarity index 100% rename from test/headers/content-range.test.ts rename to packages/binden/test/headers/content-range.test.ts diff --git a/test/headers/content-type.test.ts b/packages/binden/test/headers/content-type.test.ts similarity index 100% rename from test/headers/content-type.test.ts rename to packages/binden/test/headers/content-type.test.ts diff --git a/test/headers/cookie.test.ts b/packages/binden/test/headers/cookie.test.ts similarity index 100% rename from test/headers/cookie.test.ts rename to packages/binden/test/headers/cookie.test.ts diff --git a/test/headers/forwarded.test.ts b/packages/binden/test/headers/forwarded.test.ts similarity index 100% rename from test/headers/forwarded.test.ts rename to packages/binden/test/headers/forwarded.test.ts diff --git a/test/headers/if-modified-since.test.ts b/packages/binden/test/headers/if-modified-since.test.ts similarity index 100% rename from test/headers/if-modified-since.test.ts rename to packages/binden/test/headers/if-modified-since.test.ts diff --git a/test/headers/range.test.ts b/packages/binden/test/headers/range.test.ts similarity index 100% rename from test/headers/range.test.ts rename to packages/binden/test/headers/range.test.ts diff --git a/test/middleware.test.ts b/packages/binden/test/middleware.test.ts similarity index 100% rename from test/middleware.test.ts rename to packages/binden/test/middleware.test.ts diff --git a/test/request.test.ts b/packages/binden/test/request.test.ts similarity index 100% rename from test/request.test.ts rename to packages/binden/test/request.test.ts diff --git a/test/response.test.ts b/packages/binden/test/response.test.ts similarity index 100% rename from test/response.test.ts rename to packages/binden/test/response.test.ts diff --git a/test/router.test.ts b/packages/binden/test/router.test.ts similarity index 100% rename from test/router.test.ts rename to packages/binden/test/router.test.ts diff --git a/tsconfig.json b/packages/binden/tsconfig.json similarity index 100% rename from tsconfig.json rename to packages/binden/tsconfig.json diff --git a/typedoc.json b/packages/binden/typedoc.json similarity index 100% rename from typedoc.json rename to packages/binden/typedoc.json From 6de894169fc1d29eff29271b087016f007d022da Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 17:07:10 +0300 Subject: [PATCH 002/675] chore: import @binden/logger, @binden/body, @binden/compression and @binden/multipart Copy the package sources (index.ts, tests, README, LICENSE, CHANGELOG and package.json) from their repositories, which are archived with their full history: - binden-js/logger@e94a90e5143db2ddb4d9a205bfb76dde9eee72d6 (v2.0.3) - binden-js/body@bd44f705c55e8b4c7f624974a353d00b0d011263 - binden-js/compression@d8811c23fa2dac827df8b836377f86e14bd29e88 - binden-js/multipart@964fef684852bdf464070b64cb3771036c42b78d The unreleased changes of @binden/logger follow as separate commits. Co-Authored-By: Claude Opus 5.5 --- packages/body/CHANGELOG.md | 39 ++ packages/body/LICENSE | 661 ++++++++++++++++++ packages/body/README.md | 67 ++ packages/body/index.ts | 130 ++++ packages/body/package.json | 80 +++ packages/body/test/index.test.ts | 298 ++++++++ packages/compression/CHANGELOG.md | 40 ++ packages/compression/LICENSE | 661 ++++++++++++++++++ packages/compression/README.md | 29 + packages/compression/index.ts | 127 ++++ packages/compression/package.json | 80 +++ packages/compression/test/compression.test.ts | 303 ++++++++ packages/logger/CHANGELOG.md | 136 ++++ packages/logger/LICENSE | 661 ++++++++++++++++++ packages/logger/README.md | 76 ++ packages/logger/index.ts | 83 +++ packages/logger/package.json | 68 ++ packages/multipart/CHANGELOG.md | 57 ++ packages/multipart/LICENSE | 661 ++++++++++++++++++ packages/multipart/README.md | 69 ++ packages/multipart/index.ts | 117 ++++ packages/multipart/package.json | 81 +++ packages/multipart/test/multipart.test.ts | 396 +++++++++++ 23 files changed, 4920 insertions(+) create mode 100644 packages/body/CHANGELOG.md create mode 100644 packages/body/LICENSE create mode 100644 packages/body/README.md create mode 100644 packages/body/index.ts create mode 100644 packages/body/package.json create mode 100644 packages/body/test/index.test.ts create mode 100644 packages/compression/CHANGELOG.md create mode 100644 packages/compression/LICENSE create mode 100644 packages/compression/README.md create mode 100644 packages/compression/index.ts create mode 100644 packages/compression/package.json create mode 100644 packages/compression/test/compression.test.ts create mode 100644 packages/logger/CHANGELOG.md create mode 100644 packages/logger/LICENSE create mode 100644 packages/logger/README.md create mode 100644 packages/logger/index.ts create mode 100644 packages/logger/package.json create mode 100644 packages/multipart/CHANGELOG.md create mode 100644 packages/multipart/LICENSE create mode 100644 packages/multipart/README.md create mode 100644 packages/multipart/index.ts create mode 100644 packages/multipart/package.json create mode 100644 packages/multipart/test/multipart.test.ts diff --git a/packages/body/CHANGELOG.md b/packages/body/CHANGELOG.md new file mode 100644 index 00000000..0b0a3031 --- /dev/null +++ b/packages/body/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +## [2.0.1](https://github.com/binden-js/body/compare/v2.0.0...v2.0.1) (2024-02-01) + +## [2.0.0](https://github.com/binden-js/body/compare/v1.1.1...v2.0.0) (2024-01-30) + +### ⚠ BREAKING CHANGES + +- drop `Node.js` `<20.11.0` support + +### Performance Improvements + +- drop `Node.js` `<20.11.0` support ([1dd0c71](https://github.com/binden-js/body/commit/1dd0c7151f263aa8f4574962223ece7654af6d46)) + +## [1.1.1](https://github.com/binden-js/body/compare/v1.1.0...v1.1.1) (2023-07-13) + +### Dependencies + +- bump `binden` from `1.2.0` to `1.2.1` ([320bf27](https://github.com/binden-js/body/commit/320bf27eaa782749071df6d803a61ca7238f55c6)) + +## [1.1.0](https://github.com/binden-js/body/compare/v1.0.0...v1.1.0) (2023-03-05) + +### Features + +- allow to use a custom `parse` function ([16ae5d9](https://github.com/binden-js/body/commit/16ae5d95346ddda32c119eb624085464eef5735a)) + +## 1.0.0 (2022-09-02) + +### ⚠ BREAKING CHANGES + +- use `binden` + +### Features + +- add `BodyParser` ([37f6f18](https://github.com/binden-js/body/commit/37f6f18aa30572227518708e7f779bc8b5dcb5ea)) + +### Code Refactoring + +- use `binden` ([70fdb09](https://github.com/binden-js/body/commit/70fdb0990678a16c2d3ee37bffdaa15113dc2814)) diff --git a/packages/body/LICENSE b/packages/body/LICENSE new file mode 100644 index 00000000..162676cb --- /dev/null +++ b/packages/body/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + + Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + +The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +1. Source Code. + +The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + +The Corresponding Source for a work in source code form is that +same work. + +2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + +4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + +You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/body/README.md b/packages/body/README.md new file mode 100644 index 00000000..de6be719 --- /dev/null +++ b/packages/body/README.md @@ -0,0 +1,67 @@ +# @binden/body ![CI Status](https://github.com/binden-js/body/workflows/CI/badge.svg) [![version](https://img.shields.io/github/package-json/v/binden-js/body?style=plastic)](https://github.com/binden-js/body) [![Known Vulnerabilities](https://snyk.io/test/github/binden-js/body/badge.svg)](https://snyk.io/test/github/binden-js/body) [![Coverage Status](https://coveralls.io/repos/github/binden-js/body/badge.svg?branch=main)](https://coveralls.io/github/binden-js/body?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![GitHub top language](https://img.shields.io/github/languages/top/binden-js/body) ![node version](https://img.shields.io/node/v/@binden/body) ![npm downloads](https://img.shields.io/npm/dt/@binden/body) ![License](https://img.shields.io/github/license/binden-js/body) + +[Binden](https://github.com/binden-js/binden) body parser middleware. Supports the following [content types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) + +- `application/json` +- `application/x-www-form-urlencoded` +- `plain/text` + +and any combination of the following [encodings](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding) + +- `br` +- `x-gzip` +- `gzip` +- `deflate` + +## Installation + +```bash +npm install @binden/body +``` + +## Usage + +```typescript +import BodyParser from "@binden/body"; + +class MyMiddleware extends Middleware { + public run(context: Context): void { + const { body } = context.request; + context.log.info("Body has been parsed (or not)", { body }); + } +} + +app.use("/deposit", new Router().post(BodyParser, new MyMiddleware())); +``` + +### Custom `parse` function + +```typescript +import { Binden } from "binden"; +import AJV, { JTDDataType } from "ajv/dist/jtd.js"; +import BodyParser from "@binden/body"; +const ajv = new AJV(); +const schema = { + properties: { username: { type: "string" }, password: { type: "string" } }, + additionalProperties: false, +}; +const parse = ajv.compileParser>(schema); +const body_parser = new BodyParser({ parse }); +const echo = (context) => { + const { body } = context.request; + context.log.info("Body has been parsed (or not)", { body }); + return context.json(body); +}; +new Binden() + .use("/login", new Router().post(BodyParser, echo)) + .createServer() + .listen(port, () => { + console.log(`Server is listening on the port ${port}`); + }); +``` + +### Test + +```bash +npm test +``` diff --git a/packages/body/index.ts b/packages/body/index.ts new file mode 100644 index 00000000..d4c51b9a --- /dev/null +++ b/packages/body/index.ts @@ -0,0 +1,130 @@ +import { finished } from "node:stream"; +import { createInflate, createGunzip, createBrotliDecompress } from "node:zlib"; +import { + BindenError, + Middleware, + IMiddlewareParams, + Context, + ct_text, + ct_json, + ct_form, +} from "binden"; + +import type { Duplex, Readable } from "stream"; + +export type IBodyContentType = typeof ct_form | typeof ct_json | typeof ct_text; + +export type IParse = (input: string) => T | undefined; + +export interface IBodyParserOptions extends IMiddlewareParams { + parse?: IParse; +} + +export class BodyParser extends Middleware { + readonly #parse: IParse; + + public constructor({ parse, ...rest }: IBodyParserOptions = {}) { + super(rest); + if (typeof parse !== "undefined" && typeof parse !== "function") { + throw new TypeError("`parse` is not a function"); + } + this.#parse = parse ?? JSON.parse; + } + + public async run(context: Context): Promise { + const { name: middleware } = BodyParser; + const { request, log: logger } = context; + const log = logger.child({ middleware }); + const { destroyed, method = "GET" } = request; + + if (BodyParser.#methods.has(method)) { + log.debug("Unsupported method", { method }); + return; + } + + if (destroyed) { + log.debug("Skip parsing", { destroyed }); + return; + } + + const { content_type, content_encoding } = request; + + const type = content_type?.type ?? null; + + if (type !== ct_json && type !== ct_text && type !== ct_form) { + log.debug("Unsupported Content-Type", { content_type: type }); + return; + } + + const streams: [string, Duplex][] = []; + + for (const { encoding } of content_encoding) { + if (encoding === "gzip" || encoding === "x-gzip") { + streams.push([encoding, createGunzip()]); + } else if (encoding === "deflate") { + streams.push([encoding, createInflate()]); + } else if (encoding === "br") { + streams.push([encoding, createBrotliDecompress()]); + } else { + log.debug("Unsupported encoding", { encoding }); + throw new BindenError(415); + } + } + + const body = await new Promise((resolve, reject) => { + let { request: stream } = context as { request: Readable }; + + for (const [encoding, decompresser] of streams) { + decompresser.once("error", (error) => { + log.debug("Decoding failed", { error, encoding }); + reject(new BindenError(415, { cause: error })); + }); + stream.pipe(decompresser); + stream = decompresser; + } + + const chunks: Buffer[] = []; + + stream.on("data", (data: Buffer) => chunks.push(data)); + + finished(stream, (error) => { + if (error) { + reject(error); + } else { + resolve(Buffer.concat(chunks).toString()); + } + }); + }); + + try { + request.body = + type === ct_json + ? this.#parse(body) + : type === ct_form + ? new URLSearchParams(body) + : body; + } catch (error: unknown) { + log.debug("Request body does not match provided Content-Type", { + error, + type, + body, + }); + + throw new BindenError(415, { cause: error as Error }); + } + } + + /** Set of methods with no request body */ + public static get unsupported_methods(): Set { + return new Set(BodyParser.#methods); + } + + static readonly #methods: ReadonlySet = new Set([ + "GET", + "HEAD", + "OPTIONS", + "TRACE", + ]); +} + +export default new BodyParser(); diff --git a/packages/body/package.json b/packages/body/package.json new file mode 100644 index 00000000..2ccb61c8 --- /dev/null +++ b/packages/body/package.json @@ -0,0 +1,80 @@ +{ + "name": "@binden/body", + "version": "2.0.1", + "description": "Binden body parser middleware", + "main": "dist/index.js", + "type": "module", + "types": "dist/index.d.ts", + "engines": { + "node": ">=20.11.0", + "npm": ">=10.2.4" + }, + "scripts": { + "build": "tsc", + "build:remove": "rm -rf dist", + "ci:all": "npm ci && npm run prettier && npm run eslint && npm run commitlint && npm test && npm publish --dry-run && npm run docs:build && npm run coverage", + "commitlint": "commitlint --verbose --from=$( git rev-list --max-parents=0 $( git rev-parse --abbrev-ref HEAD ) )", + "coverage": "c8 npm test", + "docs:build": "typedoc", + "eslint": "eslint --ext .ts ./", + "eslint:fix": "npm run eslint -- --fix", + "install:clean": "npm install", + "postbuild": "prettier -c -w dist", + "postcoverage": "c8 report", + "postdocs:build": "prettier -c -w docs", + "postpublish": "npm run build:remove", + "prebuild": "npm run build:remove", + "preinstall:clean": "rm -rf node_modules package-lock.json", + "prepare": "husky", + "prepublishOnly": "npm run build", + "prettier": "prettier -c .", + "prettier:write": "prettier -c -w .", + "preversion": "npm run docs:build", + "test": "mocha" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/binden-js/body.git" + }, + "keywords": [ + "HTTP", + "binden", + "body" + ], + "author": "Sergey Bakulin ", + "license": "AGPL-3.0-only", + "directories": { + "doc": "docs", + "test": "test" + }, + "bugs": { + "url": "https://github.com/binden-js/body/issues" + }, + "homepage": "https://github.com/binden-js/body#readme", + "peerDependencies": { + "binden": "^2.0.2" + }, + "devDependencies": { + "@binden/commitlint-config": "^3.0.0", + "@binden/semantic-release-config": "^3.0.2", + "@binden/tsconfig": "^2.0.0", + "@types/mocha": "^10.0.10", + "ajv": "^8.20.0", + "c8": "^11.0.0", + "eslint-config-binden-ts": "^3.0.0", + "husky": "^9.1.7", + "lint-staged": "^15.5.1", + "mocha": "^11.7.6", + "prettier": "^3.5.3", + "ts-node": "^10.9.2", + "typedoc": "^0.28.14", + "undici": "^7.11.0" + }, + "publishConfig": { + "access": "public" + }, + "funding": { + "type": "Coinbase Commerce", + "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + } +} diff --git a/packages/body/test/index.test.ts b/packages/body/test/index.test.ts new file mode 100644 index 00000000..86952902 --- /dev/null +++ b/packages/body/test/index.test.ts @@ -0,0 +1,298 @@ +/* eslint-disable init-declarations */ +import { deepEqual, ok, fail, throws } from "node:assert/strict"; +import { Server } from "node:http"; +import { + brotliCompress as brotliCompressAsync, + gzip as gzipAsync, + deflate as deflateAsync, + InputType, +} from "node:zlib"; +import AJV, { JTDDataType } from "ajv/dist/jtd.js"; +import { errors, request } from "undici"; +import { Binden, Context, ct_text, ct_json, ct_form } from "binden"; + +import { BodyParser, IParse } from "../index.js"; + +function brotliCompress(data: InputType): Promise { + return new Promise((resolve, reject) => { + brotliCompressAsync(data, (error, result) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }); +} + +function gzip(data: InputType): Promise { + return new Promise((resolve, reject) => { + gzipAsync(data, (error, result) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }); +} + +function deflate(data: InputType): Promise { + return new Promise((resolve, reject) => { + deflateAsync(data, (error, result) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }); +} + +const port = 8080; +const url = `http://localhost:${port}/`; + +suite("BodyParser", () => { + let app: Binden; + let server: Server; + + setup((done) => { + app = new Binden(); + server = app.createServer().listen(port, done); + }); + + test("constructor (with an invalid `parse` option)", () => { + throws( + () => new BodyParser({ parse: 2 as unknown as IParse }), + new TypeError("`parse` is not a function"), + ); + }); + + test("text", async () => { + const body = "Hello World"; + const assert = (ct: Context): Promise => { + deepEqual(ct.request.body, body); + return ct.send(); + }; + + app.use(new BodyParser(), assert); + + const headers = { "Content-Type": ct_text }; + const response = await request(url, { method: "POST", body, headers }); + ok(response.statusCode === 200); + const text = await response.body.text(); + deepEqual(text, ""); + }); + + test("Unsupported methods", async () => { + const assert = (ct: Context): Promise => { + ok(typeof ct.request.body === "undefined"); + return ct.send(); + }; + + app.use(new BodyParser(), assert); + + const { unsupported_methods } = BodyParser; + // `CONNECT` is not supported, if any + unsupported_methods.delete("CONNECT"); + + for (const method of unsupported_methods) { + const headers = { "Content-Type": ct_text }; + const response = await request(url, { + method: method as "GET", + headers, + }); + ok(response.statusCode === 200); + const text = await response.body.text(); + deepEqual(text, ""); + } + }); + + test("Destroyed socket", async () => { + const promise = new Promise((resolve) => { + const destroy = (ct: Context): void => { + ct.request.destroy(); + deepEqual(ct.request.destroyed, true); + }; + const assert = (ct: Context): Promise => { + ok(typeof ct.request.body === "undefined"); + resolve(); + return ct.send(); + }; + + app.use(destroy, new BodyParser(), assert); + }); + + const body = "Hello World"; + const headers = { "Content-Type": ct_text }; + try { + await request(url, { method: "POST", body, headers }); + fail("Should throw an Error"); + } catch (error: unknown) { + ok(error instanceof errors.SocketError); + deepEqual(error.message, `other side closed`); + deepEqual(error.code, `UND_ERR_SOCKET`); + } + await promise; + }); + + test("JSON", async () => { + const expected = { message: "Hello World" }; + const assert = (ct: Context): Promise => { + deepEqual(ct.request.body, expected); + return ct.send(); + }; + + app.use(new BodyParser(), assert); + + const headers = { "Content-Type": ct_json }; + const body = JSON.stringify(expected); + const response = await request(url, { method: "POST", body, headers }); + ok(response.statusCode === 200); + const text = await response.body.text(); + deepEqual(text, ""); + }); + + test("JSON (with a custom `parse`)", async () => { + const ajv = new AJV(); + const schema = { + properties: { + username: { type: "string" }, + password: { type: "string" }, + }, + additionalProperties: false, + }; + const parse: IParse> = + ajv.compileParser>(schema); + const body_parser = new BodyParser({ parse }); + + const expected = { username: "someusername", password: "somepassword" }; + const assert = (ct: Context): Promise => { + deepEqual(ct.request.body, expected); + return ct.send(); + }; + + app.use(body_parser, assert); + + const headers = { "Content-Type": ct_json }; + const body = JSON.stringify(expected); + const response = await request(url, { method: "POST", body, headers }); + ok(response.statusCode === 200); + const text = await response.body.text(); + deepEqual(text, ""); + }); + + test("JSON (invalid)", async () => { + app.use(new BodyParser()); + + const headers = { "Content-Type": ct_json }; + const body = "Not a JSON"; + const response = await request(url, { method: "POST", body, headers }); + deepEqual(response.statusCode, 415); + const text = await response.body.text(); + deepEqual(text, ""); + }); + + test("form", async () => { + const body = new URLSearchParams({ message: "Hello World" }); + const assert = (ct: Context): Promise => { + deepEqual(ct.request.body, body); + return ct.send(); + }; + + app.use(new BodyParser(), assert); + + const headers = { "Content-Type": ct_form }; + const response = await request(url, { + method: "POST", + headers, + body: body.toString(), + }); + ok(response.statusCode === 200); + const text = await response.body.text(); + deepEqual(text, ""); + }); + + test("encoding", async () => { + const expected = { message: "Hello World" }; + const assert = (ct: Context): Promise => { + deepEqual(ct.request.body, expected); + return ct.send(); + }; + + app.use(new BodyParser(), assert); + + const brotli = await brotliCompress(JSON.stringify(expected)); + const doubleGzip = await gzip(await gzip(brotli)); + const body = await deflate(doubleGzip); + + const ce = "br, x-gzip, gzip, deflate"; + const headers = { "Content-Encoding": ce, "Content-Type": ct_json }; + const response = await request(url, { method: "POST", body, headers }); + ok(response.statusCode); + const text = await response.body.text(); + deepEqual(text, ""); + }); + + test("Missing `Content-Type`", async () => { + const body = "Hello World"; + const assert = (ct: Context): Promise => { + ok(typeof ct.request.body === "undefined"); + return ct.send(); + }; + + app.use(new BodyParser(), assert); + + const headers = { "Content-Type": "" }; + + const response = await request(url, { method: "POST", body, headers }); + deepEqual(response.statusCode, 200); + const text = await response.body.text(); + deepEqual(text, ""); + }); + + test("Unsupported `Content-Type`", async () => { + const body = "Hello World"; + const assert = (ct: Context): Promise => { + ok(typeof ct.request.body === "undefined"); + return ct.send(); + }; + + app.use(new BodyParser(), assert); + + const headers = { "Content-Type": "__unsupported__" }; + const response = await request(url, { method: "POST", body, headers }); + deepEqual(response.statusCode, 200); + const text = await response.body.text(); + deepEqual(text, ""); + }); + + test("Unsupported `Content-Encoding`", async () => { + const body = "Hello World"; + + app.use(new BodyParser()); + + const headers = { "Content-Type": ct_json, "Content-Encoding": "compress" }; + const response = await request(url, { method: "POST", body, headers }); + deepEqual(response.statusCode, 415); + const text = await response.body.text(); + deepEqual(text, ""); + }); + + test("Decompressions errors", async () => { + const body = "Hello World"; + + app.use(new BodyParser()); + + const headers = { "Content-Type": ct_text, "Content-Encoding": "br" }; + const response = await request(url, { method: "POST", body, headers }); + deepEqual(response.statusCode, 415); + const text = await response.body.text(); + deepEqual(text, ""); + }); + + teardown((done) => { + server.closeIdleConnections(); + server.close(done); + }); +}); diff --git a/packages/compression/CHANGELOG.md b/packages/compression/CHANGELOG.md new file mode 100644 index 00000000..7765c7fe --- /dev/null +++ b/packages/compression/CHANGELOG.md @@ -0,0 +1,40 @@ +# Changelog + +## [3.0.0](https://github.com/binden-js/compression/compare/v2.0.1...v3.0.0) (2026-04-14) + +### ⚠ BREAKING CHANGES + +- drop `Node.js` <24 support + +### Performance Improvements + +- drop `Node.js` <24 support ([9467525](https://github.com/binden-js/compression/commit/9467525688c95ffa3ce073b9efce738a32dae018)) + +## [2.0.1](https://github.com/binden-js/compression/compare/v2.0.0...v2.0.1) (2024-02-01) + +## [2.0.0](https://github.com/binden-js/compression/compare/v1.0.1...v2.0.0) (2024-01-28) + +### ⚠ BREAKING CHANGES + +- drop `Node.js` `<20.11.0` support + +### Performance Improvements + +- drop `Node.js` `<20.11.0` support ([7ac23f0](https://github.com/binden-js/compression/commit/7ac23f01ddce1e0489fc03f1c1b39cc4880e4473)) + +## [1.0.1](https://github.com/binden-js/compression/compare/v1.0.0...v1.0.1) (2023-08-22) + +### Dependencies + +- bump `binden` from `v1.1.1` to `v1.2.1` ([02fca44](https://github.com/binden-js/compression/commit/02fca443854acefb7737dcd69d83f8c038dd2c2a)) + +## 1.0.0 (2022-09-04) + +### Features + +- add `Compression` ([0147449](https://github.com/binden-js/compression/commit/0147449d1ea8f5ad3f6cf54b953c78efa8c40b65)) + +### Performance Improvements + +- drop Node `<18.8.0` support ([6a979d2](https://github.com/binden-js/compression/commit/6a979d27309b61a931f475ed84fe04ccd33b9ca9)) +- remove `x-gzip` from supported options ([0605dab](https://github.com/binden-js/compression/commit/0605dab10a449124617b7cc80838c1c32fe99418)) diff --git a/packages/compression/LICENSE b/packages/compression/LICENSE new file mode 100644 index 00000000..162676cb --- /dev/null +++ b/packages/compression/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + + Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + +The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +1. Source Code. + +The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + +The Corresponding Source for a work in source code form is that +same work. + +2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + +4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + +You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/compression/README.md b/packages/compression/README.md new file mode 100644 index 00000000..0a81ed46 --- /dev/null +++ b/packages/compression/README.md @@ -0,0 +1,29 @@ +# @binden/compression ![CI Status](https://github.com/binden-js/compression/workflows/CI/badge.svg) [![version](https://img.shields.io/github/package-json/v/binden-js/compression?style=plastic)](https://github.com/binden-js/compression/releases) [![Known Vulnerabilities](https://snyk.io/test/github/binden-js/compression/badge.svg)](https://snyk.io/test/github/binden-js/compression) [![Coverage Status](https://coveralls.io/repos/github/binden-js/compression/badge.svg?branch=main)](https://coveralls.io/github/binden-js/compression?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![GitHub top language](https://img.shields.io/github/languages/top/binden-js/compression) ![node version](https://img.shields.io/node/v/@binden/compression) ![npm downloads](https://img.shields.io/npm/dt/@binden/compression) [![License](https://img.shields.io/github/license/binden-js/compression)](LICENSE) + +[Binden](https://github.com/binden-js/binden) compression middleware (supports `br`, `gzip` and `deflate`). + +## Installation + +```bash +npm install @binden/compression +``` + +## Usage + +```typescript +import Compression from "@binden/compression"; + +app.use(Compression); +``` + +### Test + +```bash +npm test +``` + +### Coverage + +```bash +npm run coverage +``` diff --git a/packages/compression/index.ts b/packages/compression/index.ts new file mode 100644 index 00000000..53ee66f9 --- /dev/null +++ b/packages/compression/index.ts @@ -0,0 +1,127 @@ +import { Context, type IMiddlewareParams, Middleware } from "binden"; +import { createBrotliCompress, createDeflate, createGzip } from "node:zlib"; + +export const DefaultCompression = "br"; + +export type IComressFormats = "auto" | "br" | "deflate" | "gzip"; + +export interface ICompressionOptions extends IMiddlewareParams { + format?: IComressFormats; +} + +export class Compression extends Middleware { + readonly #format: IComressFormats; + + public constructor({ format = "auto", ...rest }: ICompressionOptions = {}) { + super(rest); + this.#format = format; + } + + public get format(): IComressFormats { + return this.#format; + } + + public run(context: Context): Context { + const format = Compression.#getFormat(context, this.format); + + if (format === "identity") { + return context; + } + + const { response } = context; + + const raw = response.getHeader("Content-Encoding"); + + response.setHeader( + "Content-Encoding", + typeof raw === "undefined" + ? format + : [format, ...(Array.isArray(raw) ? raw : [`${raw}`])], + ); + + const compress = + format === "br" + ? createBrotliCompress() + : format === "deflate" + ? createDeflate() + : createGzip(); + + compress.pipe(response); + + const proxyResponse = new Proxy(response, { + get(_r, p: string): unknown { + if (p === "write") { + return function write( + data: unknown, + encoding: BufferEncoding, + cb?: (error?: Error | null) => void, + ): void { + compress.write(data, encoding, cb); + }; + } else if (p === "end") { + return function end( + data: unknown, + encoding: BufferEncoding, + cb?: () => void, + ): void { + compress.end(data, encoding, cb); + }; + } + + return (response as unknown as Record)[p]; + }, + }); + + return new Proxy(context, { + get(_ct, p: string): unknown { + if (p === "response") { + return proxyResponse; + } + + return (context as unknown as Record)[p]; + }, + + set(_ct, p: string, value: unknown): boolean { + (context as unknown as Record)[p] = value; + return true; + }, + }); + } + + static #getFormat( + context: Context, + format: IComressFormats, + ): Exclude | "identity" { + if (format !== "auto") { + return format; + } + + const { accept_encoding } = context.request; + + if (!accept_encoding.length) { + return DefaultCompression; + } + + for (const { encoding } of accept_encoding) { + /* eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check */ + switch (encoding) { + case "*": + return DefaultCompression; + case "identity": + return "identity"; + case "x-gzip": + return "gzip"; + case "br": + case "deflate": + case "gzip": + return encoding; + default: + break; + } + } + + return "identity"; + } +} + +export default new Compression(); diff --git a/packages/compression/package.json b/packages/compression/package.json new file mode 100644 index 00000000..aca1292c --- /dev/null +++ b/packages/compression/package.json @@ -0,0 +1,80 @@ +{ + "name": "@binden/compression", + "version": "3.0.0", + "description": "binden compression middleware", + "main": "dist/index.js", + "type": "module", + "types": "dist/index.d.ts", + "engines": { + "node": ">=24.14.1", + "npm": ">=11.11.0" + }, + "scripts": { + "build": "tsc", + "build:remove": "rm -rf dist", + "ci:all": "npm ci && npm run prettier && npm run eslint && npm run commitlint && npm test && npm publish --dry-run && npm run docs:build && npm run coverage", + "commitlint": "commitlint --verbose --from=$( git rev-list --max-parents=0 $( git rev-parse --abbrev-ref HEAD ) )", + "coverage": "c8 npm test", + "docs:build": "typedoc", + "eslint": "eslint --ext .ts", + "eslint:fix": "npm run eslint -- --fix", + "install:clean": "npm install", + "postbuild": "prettier -c -w dist", + "postcoverage": "c8 report --reporter=lcov", + "postdocs:build": "prettier -c -w docs", + "postpublish": "npm run build:remove", + "prebuild": "npm run build:remove", + "preinstall:clean": "rm -rf node_modules package-lock.json", + "prepare": "husky", + "prepublishOnly": "npm run build", + "prettier": "prettier -c .", + "prettier:write": "prettier -c -w .", + "preversion": "npm run docs:build", + "test": "node --import tsx --test test/*.test.ts" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/binden-js/compression.git" + }, + "keywords": [ + "compress", + "compression", + "br", + "gzip", + "deflate", + "binden" + ], + "author": "Sergey Bakulin ", + "license": "AGPL-3.0-only", + "directories": { + "doc": "docs", + "test": "test" + }, + "bugs": { + "url": "https://github.com/binden-js/compression/issues" + }, + "homepage": "https://github.com/binden-js/compression#readme", + "peerDependencies": { + "binden": "^2.0.2" + }, + "devDependencies": { + "@binden/commitlint-config": "^3.0.0", + "@binden/eslint-config-ts": "^6.0.1", + "@binden/semantic-release-config": "^3.0.4", + "@binden/tsconfig": "^4.0.1", + "c8": "^11.0.0", + "husky": "^9.1.7", + "lint-staged": "^17.3.0", + "prettier": "^3.8.3", + "tsx": "^4.22.5", + "typedoc": "^0.28.19", + "undici": "^8.3.0" + }, + "publishConfig": { + "access": "public" + }, + "funding": { + "type": "Coinbase Commerce", + "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + } +} diff --git a/packages/compression/test/compression.test.ts b/packages/compression/test/compression.test.ts new file mode 100644 index 00000000..34654cf0 --- /dev/null +++ b/packages/compression/test/compression.test.ts @@ -0,0 +1,303 @@ +/* eslint-disable init-declarations, @typescript-eslint/no-floating-promises, sort-imports */ +import { deepStrictEqual, ok } from "node:assert"; +import type { Server } from "node:http"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { + type InputType, + brotliDecompress as brotliDecompressAsync, + gunzip as gunzipAsync, + inflate as inflateAsync, +} from "node:zlib"; + +import { Binden, Context, Middleware } from "binden"; +import { request } from "undici"; + +import { + Compression, + DefaultCompression, + type IComressFormats, +} from "../index.js"; + +function brotliDecompress(data: InputType): Promise { + return new Promise((resolve, reject) => { + brotliDecompressAsync(data, (error, result) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }); +} + +function gunzip(data: InputType): Promise { + return new Promise((resolve, reject) => { + gunzipAsync(data, (error, result) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }); +} + +function inflate(data: InputType): Promise { + return new Promise((resolve, reject) => { + inflateAsync(data, (error, result) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }); +} + +const port = 8080; +const url = new URL(`http://localhost:${port}`); + +class TestMiddleware extends Middleware { + readonly #data: Buffer | string | undefined; + public constructor(data?: Buffer | string) { + super(); + this.#data = data; + } + public run(ct: unknown): Promise { + ok(ct instanceof Context); + return ct.send(this.#data); + } +} + +describe("Compression", () => { + let app: Binden; + let server: Server; + + beforeEach(async () => { + await new Promise((resolve) => { + app = new Binden(); + server = app.createServer().listen(port, resolve); + }); + }); + + it("constructor (with default options)", async () => { + const expected = "Hello World"; + const format = "deflate"; + app.use(new Compression(), new TestMiddleware(expected)); + + const headers = { "Accept-Encoding": format }; + const response = await request(url, { headers }); + + deepStrictEqual(response.headers["content-encoding"], format); + deepStrictEqual(response.statusCode, 200); + + const body = await response.body.arrayBuffer(); + const actual = (await inflate(body)).toString(); + + deepStrictEqual(actual, expected); + }); + + it("auto (x-gzip)", async () => { + const expected = "Hello World"; + app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); + + const headers = { "Accept-Encoding": "x-gzip" }; + const response = await request(url, { headers }); + + deepStrictEqual(response.headers["content-encoding"], "gzip"); + deepStrictEqual(response.statusCode, 200); + + const body = await response.body.arrayBuffer(); + const actual = (await gunzip(body)).toString(); + + deepStrictEqual(actual, expected); + }); + + it("auto (gzip)", async () => { + const expected = "Hello World"; + const format = "gzip"; + app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); + + const headers = { "Accept-Encoding": format }; + const response = await request(url, { headers }); + + deepStrictEqual(response.headers["content-encoding"], format); + deepStrictEqual(response.statusCode, 200); + + const body = await response.body.arrayBuffer(); + const actual = (await gunzip(body)).toString(); + + deepStrictEqual(actual, expected); + }); + + it("auto (br)", async () => { + const expected = "Hello World"; + const format = "br"; + app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); + + const headers = { "Accept-Encoding": format }; + const response = await request(url, { headers }); + + deepStrictEqual(response.headers["content-encoding"], format); + deepStrictEqual(response.statusCode, 200); + + const body = await response.body.arrayBuffer(); + const actual = (await brotliDecompress(body)).toString(); + + deepStrictEqual(actual, expected); + }); + + it("auto (identity)", async () => { + const expected = "Hello World"; + const format = "* ;q= 0.6, identity ;q= 0.7"; + app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); + + const headers = { "Accept-Encoding": format }; + const response = await request(url, { headers }); + + ok(typeof response.headers["content-encoding"] === "undefined"); + deepStrictEqual(response.statusCode, 200); + + const body = await response.body.arrayBuffer(); + const actual = Buffer.from(body).toString(); + + deepStrictEqual(actual, expected); + }); + + it("auto (*)", async () => { + const expected = "Hello World"; + const format = "* ;q= 0.8, identity ;q= 0.7"; + app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); + + const headers = { "Accept-Encoding": format }; + const response = await request(url, { headers }); + + deepStrictEqual(response.headers["content-encoding"], DefaultCompression); + deepStrictEqual(response.statusCode, 200); + + const body = await response.body.arrayBuffer(); + const actual = (await brotliDecompress(body)).toString(); + + deepStrictEqual(actual, expected); + }); + + it("auto (Default compression when no `Accept-Encoding` header is present)", async () => { + const expected = "Hello World"; + app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); + + const headers = { "Accept-Encoding": "" }; + const response = await request(url, { headers }); + + deepStrictEqual(response.headers["content-encoding"], DefaultCompression); + deepStrictEqual(response.statusCode, 200); + + const body = await response.body.arrayBuffer(); + const actual = (await brotliDecompress(body)).toString(); + + deepStrictEqual(actual, expected); + }); + + it("auto (unsupported)", async () => { + const expected = "Hello World"; + const format = "compress"; + app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); + + const headers = { "Accept-Encoding": format }; + const response = await request(url, { headers }); + + ok(typeof response.headers["content-encoding"] === "undefined"); + deepStrictEqual(response.statusCode, 200); + + const body = await response.body.arrayBuffer(); + const actual = Buffer.from(body).toString(); + + deepStrictEqual(actual, expected); + }); + + it("gzip", async () => { + const expected = "Hello World"; + const format = "gzip"; + app.use(new Compression({ format }), new TestMiddleware(expected)); + + const response = await request(url); + + deepStrictEqual(response.headers["content-encoding"], format); + deepStrictEqual(response.statusCode, 200); + + const raw = await response.body.arrayBuffer(); + const actual = (await gunzip(raw)).toString(); + + deepStrictEqual(actual, expected); + }); + + it("br", async () => { + const expected = "Hello World"; + const format = "br"; + app.use(new Compression({ format }), new TestMiddleware(expected)); + + const response = await request(url); + + deepStrictEqual(response.headers["content-encoding"], format); + deepStrictEqual(response.statusCode, 200); + + const raw = await response.body.arrayBuffer(); + const actual = (await brotliDecompress(raw)).toString(); + + deepStrictEqual(actual, expected); + }); + + it("deflate", async () => { + const expected = "Hello World"; + const format = "deflate"; + app.use(new Compression({ format }), new TestMiddleware(expected)); + + const response = await request(url); + + deepStrictEqual(response.headers["content-encoding"], format); + deepStrictEqual(response.statusCode, 200); + + const raw = await response.body.arrayBuffer(); + const actual = (await inflate(raw)).toString(); + + deepStrictEqual(actual, expected); + }); + + it("Multiple compressions", async () => { + const expected = "Hello World"; + const formats: readonly IComressFormats[] = ["gzip", "br", "deflate"]; + app.use( + ...formats.map((format) => new Compression({ format })), + new TestMiddleware(expected), + ); + + const response = await request(url); + + deepStrictEqual( + response.headers["content-encoding"], + [...formats].reverse(), + ); + deepStrictEqual(response.statusCode, 200); + + const body = await response.body.arrayBuffer(); + const gzip = await gunzip(Buffer.from(body)); + const br = await brotliDecompress(gzip); + const actual = (await inflate(br)).toString(); + + deepStrictEqual(actual, expected); + }); + + afterEach(async () => { + await new Promise((resolve, reject) => { + server.closeIdleConnections(); + setTimeout(() => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }, 5); + }); + }); +}); diff --git a/packages/logger/CHANGELOG.md b/packages/logger/CHANGELOG.md new file mode 100644 index 00000000..d2d3bd45 --- /dev/null +++ b/packages/logger/CHANGELOG.md @@ -0,0 +1,136 @@ +# Changelog + +## [2.0.3](https://github.com/binden-js/logger/compare/v2.0.2...v2.0.3) (2024-12-07) + +### Dependencies + +- bump pino from 9.2.0 to 9.5.0 ([66a42b5](https://github.com/binden-js/logger/commit/66a42b5000ad61f2bd5cb8b49fdb7dcf2a3dccc6)) + +## [2.0.2](https://github.com/binden-js/logger/compare/v2.0.1...v2.0.2) (2024-06-29) + +### Bug Fixes + +- update `eslint` ([a928b0b](https://github.com/binden-js/logger/commit/a928b0b9ebf6c30dd4b78e8a6dc590beacec30a9)) + +## [2.0.1](https://github.com/binden-js/logger/compare/v2.0.0...v2.0.1) (2024-06-17) + +### Dependencies + +- bump pino from 8.17.2 to 9.2.0 ([4d57e08](https://github.com/binden-js/logger/commit/4d57e08a6bc78e06b0676fc9d24679ebb0534675)) + +## [2.0.0](https://github.com/binden-js/logger/compare/v1.0.13...v2.0.0) (2024-01-30) + +### ⚠ BREAKING CHANGES + +- drop `Node.js` `<20.11.0` support + +### Performance Improvements + +- drop `Node.js` `<20.11.0` support ([a84aa56](https://github.com/binden-js/logger/commit/a84aa5658379ce39dc37db214817ff1292d4afef)) + +## [1.0.13](https://github.com/binden-js/logger/compare/v1.0.12...v1.0.13) (2023-12-29) + +### Dependencies + +- bump pino from 8.16.2 to 8.17.2 ([49b1ba8](https://github.com/binden-js/logger/commit/49b1ba8ae84e52f7b595bd1fd750331d9995d9bc)) + +## [1.0.12](https://github.com/binden-js/logger/compare/v1.0.11...v1.0.12) (2023-11-27) + +### Dependencies + +- bump pino from 8.16.1 to 8.16.2 ([fbcfb07](https://github.com/binden-js/logger/commit/fbcfb076e27bfbcdf8cca07fdbdc8bc944f193f5)) + +## [1.0.11](https://github.com/binden-js/logger/compare/v1.0.10...v1.0.11) (2023-11-15) + +### Dependencies + +- bump pino from 8.15.1 to 8.16.1 ([8208eb1](https://github.com/binden-js/logger/commit/8208eb193b38605e31441dff3fdb0ef8e54c6c74)) + +## [1.0.10](https://github.com/binden-js/logger/compare/v1.0.9...v1.0.10) (2023-09-08) + +### Dependencies + +- bump pino from 8.15.0 to 8.15.1 ([f578dbc](https://github.com/binden-js/logger/commit/f578dbc143df72206c2c2e9a6ad7597062d0d288)) + +## [1.0.9](https://github.com/binden-js/logger/compare/v1.0.8...v1.0.9) (2023-09-07) + +### Dependencies + +- bump pino from 8.14.1 to 8.15.0 ([d920a6b](https://github.com/binden-js/logger/commit/d920a6bb9a6b6cbef4220ffce4ca708ba6f50de8)) + +## [1.0.8](https://github.com/binden-js/logger/compare/v1.0.7...v1.0.8) (2023-05-25) + +### Dependencies + +- bump pino from 8.11.0 to 8.14.1 ([049c0a7](https://github.com/binden-js/logger/commit/049c0a713ef78ab86186bc870b32e1fba79f0f36)) + +## [1.0.7](https://github.com/binden-js/logger/compare/v1.0.6...v1.0.7) (2023-02-24) + +### Dependencies + +- bump pino from 8.8.0 to 8.11.0 ([315deea](https://github.com/binden-js/logger/commit/315deea01460b6ad1a563832d221a0b0bc50d195)) + +## [1.0.6](https://github.com/binden-js/logger/compare/v1.0.5...v1.0.6) (2022-12-27) + +### Dependencies + +- bump pino from 8.7.0 to 8.8.0 ([e973c34](https://github.com/binden-js/logger/commit/e973c3494731d2bc407db1af31b33c9803cc8453)) + +## [1.0.5](https://github.com/binden-js/logger/compare/v1.0.4...v1.0.5) (2022-10-23) + +### Dependencies + +- bump pino from 8.6.1 to 8.7.0 ([9fe3b7c](https://github.com/binden-js/logger/commit/9fe3b7c59eb4dcb7622a738677577ad1da9aac34)) + +## [1.0.4](https://github.com/binden-js/logger/compare/v1.0.3...v1.0.4) (2022-10-04) + +### Dependencies + +- bump pino from 8.5.0 to 8.6.1 ([bf99808](https://github.com/binden-js/logger/commit/bf9980849016ba30310a324daddbd619d1a85287)) + +## [1.0.3](https://github.com/binden-js/logger/compare/v1.0.2...v1.0.3) (2022-09-05) + +### Dependencies + +- bump `pino` from `8.4.2` to `8.5.0` ([4a63353](https://github.com/binden-js/logger/commit/4a63353c4959db93a0658499a0fa11cb3471283f)) + +## [1.0.2](https://github.com/binden-js/logger/compare/v1.0.1...v1.0.2) (2022-08-21) + +### Bug Fixes + +- set the default logging level to `BINDEN_LOG_LEVEL` value ([ecc3e22](https://github.com/binden-js/logger/commit/ecc3e22fea74c2959d85386cd9489f5d6f847cde)) + +### Performance Improvements + +- remove the `ILevel` interface ([821f2dd](https://github.com/binden-js/logger/commit/821f2dd89b5565f7fc7ed4cd03a019cc57889eb2)) + +## [1.0.1](https://github.com/binden-js/logger/compare/v1.0.0...v1.0.1) (2022-08-21) + +### Dependencies + +- bump pino from 8.4.1 to 8.4.2 ([54a7d85](https://github.com/binden-js/logger/commit/54a7d85a4a83bb89391a23861e92c107f1e5ad26)) + +## 1.0.0 (2022-08-14) + +### ⚠ BREAKING CHANGES + +- update `constructor` signature + +### Features + +- add `Logger` ([3cec071](https://github.com/binden-js/logger/commit/3cec071224127ba59caf3e86193477ea1fac0e06)) + +### Performance Improvements + +- use default formatters ([325d623](https://github.com/binden-js/logger/commit/325d6238f98a3000d124ba6adf3c63b3063fc6ec)) + +### Code Refactoring + +- update `constructor` signature ([25450f2](https://github.com/binden-js/logger/commit/25450f22e16233d7008b608ba6c489452bd2c0a8)) + +### Dependencies + +- add `pino` ([2cac021](https://github.com/binden-js/logger/commit/2cac021111cbf7225eab4efe0b17ba39a877dfe9)) +- bump `pino` from `7.5.1` to `8.4.0` ([29eaad9](https://github.com/binden-js/logger/commit/29eaad936ab79d4acc8acbcb0bed10096ac40571)) +- bump `pino` from `8.4.0` to `8.4.1` ([9a8ae8a](https://github.com/binden-js/logger/commit/9a8ae8a1a75125b248d291571ce2ab8621871439)) +- upgrade `pino` to `v7.5.1` ([2d25c55](https://github.com/binden-js/logger/commit/2d25c55dd37c71f55998807cb29c055015ad56d5)) diff --git a/packages/logger/LICENSE b/packages/logger/LICENSE new file mode 100644 index 00000000..162676cb --- /dev/null +++ b/packages/logger/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + + Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + +The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +1. Source Code. + +The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + +The Corresponding Source for a work in source code form is that +same work. + +2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + +4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + +You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/logger/README.md b/packages/logger/README.md new file mode 100644 index 00000000..f4010ada --- /dev/null +++ b/packages/logger/README.md @@ -0,0 +1,76 @@ +# @binden/logger ![CI Status](https://github.com/binden-js/logger/workflows/CI/badge.svg) [![version](https://img.shields.io/github/package-json/v/binden-js/logger?style=plastic)](https://github.com/binden-js/logger/releases) [![Known Vulnerabilities](https://snyk.io/test/github/binden-js/logger/badge.svg)](https://snyk.io/test/github/binden-js/logger) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![GitHub top language](https://img.shields.io/github/languages/top/binden-js/logger) ![node version](https://img.shields.io/node/v/@binden/logger) ![npm downloads](https://img.shields.io/npm/dt/@binden/logger) [![License](https://img.shields.io/github/license/binden-js/logger)](LICENSE) + +A simple logger (based on [`pino`](https://github.com/pinojs/pino)). + +By default, the logging level is equal to the value of the `BINDEN_LOG_LEVEL` environmental variable. + +```bash +BINDEN_LOG_LEVEL=trace +``` + +## Installation + +```bash +npm install @binden/logger +``` + +## Usage + +```javascript +import { Logger } from "@binden/logger"; +const logger = new Logger({ level: Logger.getLevel("OTHER_ENV_NAME") }); +``` + +or using the default import + +```javascript +import log from "@binden/logger"; +``` + +- `.fatal()` + +```javascript +log.fatal("Bad error. Exiting...", { id: 1, someinfo: { a: 2 } }); +``` + +- `.error()` + +```javascript +try { + doSomething(); +} catch (error) { + log.error("Something bad happened in the `doSomething`", { error }); +} +``` + +- `.warn()` + +```javascript +if (connection) { + workWithConnection(); +} else { + log.warn("The connection has not been established", { reason }); +} +``` + +- `.info()` + +```javascript +server.on("listening", (port) => { + log.info(`The server is listening`, { port }); +}); +``` + +- `.debug()` + +```javascript +websocket.on("message", (message) => { + log.debug("Received a message", { message }); +}); +``` + +- `.trace()` + +```javascript +log.trace("Array length", { length: arr.length }); +``` diff --git a/packages/logger/index.ts b/packages/logger/index.ts new file mode 100644 index 00000000..66d8f814 --- /dev/null +++ b/packages/logger/index.ts @@ -0,0 +1,83 @@ +import pino, { + stdSerializers, + Bindings, + Logger as PinoLogger, + LoggerOptions as PinoLoggerOptions, + DestinationStream, + LevelWithSilent, +} from "pino"; + +export interface LoggerOptions extends PinoLoggerOptions { + destination?: DestinationStream; + logger?: PinoLogger; +} + +const serializers = { + error: stdSerializers.err, + request: stdSerializers.req, + response: stdSerializers.res, +}; + +export class Logger { + readonly #logger: PinoLogger; + + public constructor({ logger, destination, ...options }: LoggerOptions = {}) { + this.#logger = destination + ? pino({ level: Logger.getLevel(), serializers, ...options }, destination) + : logger ?? pino({ level: Logger.getLevel(), serializers, ...options }); + } + + public child(params: Bindings = {}): Logger { + return new Logger({ logger: this.#logger.child(params) }); + } + + public fatal(message: string, extra: Record = {}): void { + this.#logger.fatal(extra, message); + } + + public error(message: string, extra: Record = {}): void { + this.#logger.error(extra, message); + } + + public warn(message: string, extra: Record = {}): void { + this.#logger.warn(extra, message); + } + + public info(message: string, extra: Record = {}): void { + this.#logger.info(extra, message); + } + + public debug(message: string, extra: Record = {}): void { + this.#logger.debug(extra, message); + } + + public trace(message: string, extra: Record = {}): void { + this.#logger.trace(extra, message); + } + + public static get ENV_VARIABLE_NAME(): string { + return "BINDEN_LOG_LEVEL"; + } + + public static getLevel(env_name = this.ENV_VARIABLE_NAME): LevelWithSilent { + const { + env: { [env_name]: LEVEL }, + } = process; + + const level = LEVEL?.trim().toLowerCase(); + + switch (level) { + case "trace": + case "debug": + case "info": + case "warn": + case "error": + case "fatal": + return level; + default: + return "silent"; + } + } +} + +export default new Logger(); diff --git a/packages/logger/package.json b/packages/logger/package.json new file mode 100644 index 00000000..85b513f0 --- /dev/null +++ b/packages/logger/package.json @@ -0,0 +1,68 @@ +{ + "name": "@binden/logger", + "version": "2.0.3", + "description": "A simple logger (based on pino)", + "main": "dist/index.js", + "type": "module", + "types": "dist/index.d.ts", + "engines": { + "node": ">=20.11.0", + "npm": ">=10.2.4" + }, + "scripts": { + "build": "tsc", + "build:remove": "rm -rf ./dist", + "ci:all": "npm ci && npm run prettier && npm run eslint && npm run commitlint && npm run docs:build && npm publish --dry-run", + "commitlint": "commitlint --verbose --from=$( git rev-list --max-parents=0 $( git rev-parse --abbrev-ref HEAD ) )", + "docs:build": "typedoc", + "eslint": "eslint **/*.ts", + "install:clean": "npm install", + "postbuild": "prettier -c -w ./dist", + "postdocs:build": "prettier -c -w ./docs", + "postpublish": "npm run build:remove", + "prebuild": "npm run build:remove", + "preinstall:clean": "rm -rf node_modules package-lock.json", + "prepare": "husky", + "prepublishOnly": "npm run build", + "prettier": "prettier -c .", + "prettier:write": "prettier -c -w .", + "preversion": "npm run docs:build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/binden-js/logger.git" + }, + "keywords": [ + "logger" + ], + "author": "Sergey Bakulin ", + "license": "AGPL-3.0-only", + "directories": { + "doc": "docs" + }, + "bugs": { + "url": "https://github.com/binden-js/logger/issues" + }, + "homepage": "https://github.com/binden-js/logger#readme", + "dependencies": { + "pino": "^9.5.0" + }, + "devDependencies": { + "@binden/commitlint-config": "^2.1.0", + "@binden/semantic-release-config": "^3.0.0", + "@binden/tsconfig": "^2.0.0", + "eslint-config-binden-ts": "^4.0.2", + "husky": "^9.1.7", + "lint-staged": "^15.2.10", + "prettier": "^3.3.2", + "ts-node": "^10.9.2", + "typedoc": "^0.26.8" + }, + "publishConfig": { + "access": "public" + }, + "funding": { + "type": "Coinbase Commerce", + "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + } +} diff --git a/packages/multipart/CHANGELOG.md b/packages/multipart/CHANGELOG.md new file mode 100644 index 00000000..b29ff837 --- /dev/null +++ b/packages/multipart/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +## 2.0.3 (2025-06-29) + +- Merge branch 'main' into dependabot/npm_and_yarn/binden/tsconfig-3.0.0 ([9f25869](https://github.com/binden-js/multipart/commit/9f25869)) +- Merge pull request #104 from binden-js/dependabot/npm_and_yarn/husky-9.1.7 ([07272cb](https://github.com/binden-js/multipart/commit/07272cb)), closes [#104](https://github.com/binden-js/multipart/issues/104) +- Merge pull request #109 from binden-js/dependabot/npm_and_yarn/prettier-3.4.2 ([83f866c](https://github.com/binden-js/multipart/commit/83f866c)), closes [#109](https://github.com/binden-js/multipart/issues/109) +- Merge pull request #110 from binden-js/dependabot/npm_and_yarn/typedoc-0.27.3 ([af5b39a](https://github.com/binden-js/multipart/commit/af5b39a)), closes [#110](https://github.com/binden-js/multipart/issues/110) +- Merge pull request #113 from binden-js/dependabot/npm_and_yarn/binden/tsconfig-3.0.0 ([2821a59](https://github.com/binden-js/multipart/commit/2821a59)), closes [#113](https://github.com/binden-js/multipart/issues/113) +- Merge pull request #115 from binden-js/dependabot/npm_and_yarn/c8-10.1.3 ([06b3b68](https://github.com/binden-js/multipart/commit/06b3b68)), closes [#115](https://github.com/binden-js/multipart/issues/115) +- Merge pull request #121 from binden-js/dependabot/npm_and_yarn/lint-staged-15.4.3 ([d6a6d47](https://github.com/binden-js/multipart/commit/d6a6d47)), closes [#121](https://github.com/binden-js/multipart/issues/121) +- Merge pull request #125 from binden-js/dependabot/npm_and_yarn/typedoc-0.27.9 ([2d99911](https://github.com/binden-js/multipart/commit/2d99911)), closes [#125](https://github.com/binden-js/multipart/issues/125) +- Merge pull request #135 from binden-js/dependabot/npm_and_yarn/lint-staged-16.1.2 ([a5a8977](https://github.com/binden-js/multipart/commit/a5a8977)), closes [#135](https://github.com/binden-js/multipart/issues/135) +- Merge pull request #139 from binden-js/dependabot/npm_and_yarn/typedoc-0.28.6 ([ba26207](https://github.com/binden-js/multipart/commit/ba26207)), closes [#139](https://github.com/binden-js/multipart/issues/139) +- perf!: drop Node<22 support ([e0e30b6](https://github.com/binden-js/multipart/commit/e0e30b6)) +- ci: do not use `npm publish --dry-run` ([d788587](https://github.com/binden-js/multipart/commit/d788587)) +- ci: remove intermediate versions ([4975609](https://github.com/binden-js/multipart/commit/4975609)) +- test: fix eslint errors ([f655302](https://github.com/binden-js/multipart/commit/f655302)) +- test: update types ([f9eed00](https://github.com/binden-js/multipart/commit/f9eed00)) +- refactor: update type imports ([998f303](https://github.com/binden-js/multipart/commit/998f303)) +- style: run prettier ([8b1eb3a](https://github.com/binden-js/multipart/commit/8b1eb3a)) +- peerDependencies: bump `binden` to `v2.0.4` ([54027e9](https://github.com/binden-js/multipart/commit/54027e9)) +- devDependencies: bump @binden/tsconfig from 2.0.0 to 3.0.0 ([92bfed4](https://github.com/binden-js/multipart/commit/92bfed4)) +- devDependencies: bump c8 from 10.1.2 to 10.1.3 ([9796211](https://github.com/binden-js/multipart/commit/9796211)) +- devDependencies: bump husky from 9.1.5 to 9.1.7 ([6ce162b](https://github.com/binden-js/multipart/commit/6ce162b)) +- devDependencies: bump lint-staged from 15.2.10 to 15.4.3 ([f428725](https://github.com/binden-js/multipart/commit/f428725)) +- devDependencies: bump lint-staged from 15.4.3 to 16.1.2 ([977a531](https://github.com/binden-js/multipart/commit/977a531)) +- devDependencies: bump prettier from 3.3.3 to 3.4.2 ([d73fc0f](https://github.com/binden-js/multipart/commit/d73fc0f)) +- devDependencies: bump typedoc from 0.26.7 to 0.27.3 ([e8e03b6](https://github.com/binden-js/multipart/commit/e8e03b6)) +- devDependencies: bump typedoc from 0.27.3 to 0.27.9 ([fd61f05](https://github.com/binden-js/multipart/commit/fd61f05)) +- devDependencies: bump typedoc from 0.27.9 to 0.28.6 ([dd1efd8](https://github.com/binden-js/multipart/commit/dd1efd8)) + +## [2.0.2](https://github.com/binden-js/multipart/compare/v2.0.1...v2.0.2) (2024-09-10) + +## [2.0.1](https://github.com/binden-js/multipart/compare/v2.0.0...v2.0.1) (2024-02-01) + +## [2.0.0](https://github.com/binden-js/multipart/compare/v1.0.1...v2.0.0) (2024-01-30) + +### ⚠ BREAKING CHANGES + +- drop `Node.js` `<20.11.0` support + +### Performance Improvements + +- drop `Node.js` `<20.11.0` support ([d637262](https://github.com/binden-js/multipart/commit/d637262ff58b4a071ebf4328488b1d30a349aa68)) + +## [1.0.1](https://github.com/binden-js/multipart/compare/v1.0.0...v1.0.1) (2023-07-12) + +### Dependencies + +- bump `binden` from `1.2.0` to `1.2.1` ([dafe274](https://github.com/binden-js/multipart/commit/dafe2745253c465247a70ffc295070daa22c9877)) + +## 1.0.0 (2023-03-02) + +### Features + +- add the `Multipart` class ([1309ba7](https://github.com/binden-js/multipart/commit/1309ba7ced8ddafdb2ab680cd40608638dae1b8e)) diff --git a/packages/multipart/LICENSE b/packages/multipart/LICENSE new file mode 100644 index 00000000..162676cb --- /dev/null +++ b/packages/multipart/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + + Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + +The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +1. Source Code. + +The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + +The Corresponding Source for a work in source code form is that +same work. + +2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + +4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + +You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/multipart/README.md b/packages/multipart/README.md new file mode 100644 index 00000000..35d2d2af --- /dev/null +++ b/packages/multipart/README.md @@ -0,0 +1,69 @@ +# @binden/multipart ![CI Status](https://github.com/binden-js/multipart/workflows/CI/badge.svg) [![version](https://img.shields.io/github/package-json/v/binden-js/multipart?style=plastic)](https://github.com/binden-js/multipart) [![Known Vulnerabilities](https://snyk.io/test/github/binden-js/multipart/badge.svg)](https://snyk.io/test/github/binden-js/multipart) [![Coverage Status](https://coveralls.io/repos/github/binden-js/multipart/badge.svg?branch=main)](https://coveralls.io/github/binden-js/multipart?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![GitHub top language](https://img.shields.io/github/languages/top/binden-js/multipart) ![node version](https://img.shields.io/node/v/@binden/multipart) ![npm downloads](https://img.shields.io/npm/dt/@binden/multipart) ![License](https://img.shields.io/github/license/binden-js/multipart) + +[Binden](https://github.com/binden-js/binden) `multipart/form-data` parser middleware (based on [busboy](https://github.com/mscdex/busboy)). + +## Installation + +```bash +npm install @binden/multipart +``` + +## Usage + +```typescript +import multipart from "@binden/multipart"; + +const formDataParser = multipart({ limits: { fields: 2, fileSize: 1024 } }); + +const next = (context) => { + if (context.request.body instanceof FormData) { + context.log.info("Body has been parsed successfully"); + return context.status(200).text("GOOD"); + } else { + context.log.warn("Body is not a valid `form-data`"); + return context.status(400).text("BAD"); + } +}; + +app.use("/formdata", new Router().post(formDataParser, next)); +``` + +### Truncated `FormData` + +Set the `throw_limits` to `false` to avoid throwing errors. `FormData` might be truncated due to `config.limits` (See `busboy` [limits](https://github.com/mscdex/busboy/#api)) + +```typescript +import { Multipart } from "@binden/multipart"; + +const formDataParser = new Multipart({ + throw_limits: false, + config: { + limits: { + fieldNameSize: 5, + fieldSize: 16 * 1024, + fields: 4, + fileSize: 128 * 1024, + files: 4, + parts: 6, + }, + }, +}); + +const next = (context) => { + if (context.request.body instanceof FormData) { + context.log.info("Fields and files might have been truncated"); + return context.status(200).text("MAYBE GOOD"); + } else { + context.log.warn("Body is not a valid `form-data`"); + return context.status(400).text("BAD"); + } +}; + +app.use("/formdata", new Router().post(formDataParser, next)); +``` + +### Test + +```bash +npm test +``` diff --git a/packages/multipart/index.ts b/packages/multipart/index.ts new file mode 100644 index 00000000..4b0c27fe --- /dev/null +++ b/packages/multipart/index.ts @@ -0,0 +1,117 @@ +import { + BindenError, + type Context, + type IMiddlewareParams, + Middleware, +} from "binden"; +import busboy, { type BusboyConfig } from "busboy"; + +export type IMultipartConfig = Omit; + +export interface IMultipartOptions extends IMiddlewareParams { + config?: IMultipartConfig; + throw_limits?: boolean; +} + +export class Multipart extends Middleware { + readonly #config: IMultipartConfig; + readonly #throw_limits: boolean; + + public constructor({ + config = {}, + throw_limits = true, + ...rest + }: IMultipartOptions = {}) { + super(rest); + this.#config = config; + this.#throw_limits = throw_limits; + } + + public async run(context: Context): Promise { + const { log, request } = context; + const { headers, method, content_type } = request; + + /* eslint-disable @typescript-eslint/switch-exhaustiveness-check */ + switch (method) { + case "GET": + case "HEAD": + case "OPTIONS": + case "TRACE": + log.trace("Method does not support incoming body", { method }); + return; + default: + break; + } + + if ( + typeof content_type?.boundary === "undefined" || + content_type.boundary === null + ) { + log.trace("`Content-Type` is not `multipart/form-data`", { + "Content-Type": content_type?.type, + }); + return; + } + + await new Promise((resolve, reject) => { + const fd = new FormData(); + const bb = busboy({ headers, ...this.#config }) + .once("error", (cause) => { + const message = "`busboy` internal error"; + reject(new BindenError(415, { message, cause })); + }) + .once("close", () => { + request.body = fd; + resolve(); + }) + .on("field", (name, value) => { + fd.append(name, value); + }) + .on("file", (name, stream, { mimeType: type, filename }) => { + const chunks: Buffer[] = []; + stream + .once("close", () => { + fd.append(name, new File(chunks, filename, { type })); + }) + .on("data", (chunk: Buffer) => { + chunks.push(chunk); + }) + .once("limit", () => { + if (this.#throw_limits) { + const cause = new Error("`busboy` reached `fileSize` limit "); + const error = new BindenError(415, { cause }); + reject(error); + } + }); + }) + .on("fieldsLimit", () => { + if (this.#throw_limits) { + const cause = new Error("`busboy` reached fields limit "); + const error = new BindenError(415, { cause }); + reject(error); + } + }) + .on("filesLimit", () => { + if (this.#throw_limits) { + const cause = new Error("`busboy` reached files limit "); + const error = new BindenError(415, { cause }); + reject(error); + } + }) + .once("partsLimit", () => { + if (this.#throw_limits) { + const cause = new Error("`busboy` reached parts limit "); + const error = new BindenError(415, { cause }); + reject(error); + } else { + request.body = fd; + resolve(); + } + }); + request.pipe(bb); + }); + } +} + +export default (config: IMultipartConfig = {}): Multipart => + new Multipart({ config }); diff --git a/packages/multipart/package.json b/packages/multipart/package.json new file mode 100644 index 00000000..2da49b1e --- /dev/null +++ b/packages/multipart/package.json @@ -0,0 +1,81 @@ +{ + "name": "@binden/multipart", + "version": "2.0.3", + "description": "Binden middleware for parsing incoming FormData", + "main": "./dist/index.js", + "type": "module", + "types": "./dist/index.d.ts", + "directories": { + "doc": "docs", + "test": "test" + }, + "engines": { + "node": ">=22.17.0", + "npm": ">=10.9.2" + }, + "scripts": { + "build": "tsc", + "build:remove": "rm -rf dist", + "ci:all": "npm ci && npm run prettier && npm run eslint && npm run commitlint && npm test && npm publish --dry-run && npm run docs:build && npm run coverage", + "commitlint": "commitlint --verbose --from=$( git rev-list --max-parents=0 $( git rev-parse --abbrev-ref HEAD ) )", + "coverage": "c8 npm test", + "docs:build": "typedoc", + "eslint": "eslint **/*.ts", + "eslint:fix": "npm run eslint -- --fix", + "install:clean": "npm install", + "postbuild": "prettier -c -w dist", + "postcoverage": "c8 report", + "postdocs:build": "prettier -c -w docs", + "postpublish": "npm run build:remove", + "prebuild": "npm run build:remove", + "preinstall:clean": "rm -rf node_modules package-lock.json", + "prepare": "husky", + "prepublishOnly": "npm run build", + "prettier": "prettier -c .", + "prettier:write": "prettier -c -w .", + "preversion": "npm run docs:build", + "test": "node --loader ts-node/esm --test test/*.test.ts" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/binden-js/multipart.git" + }, + "keywords": [ + "multipart", + "formdata", + "binden", + "busboy" + ], + "author": "Sergey Bakulin ", + "license": "AGPL-3.0-only", + "dependencies": { + "busboy": "^1.6.0" + }, + "peerDependencies": { + "@types/busboy": "^1.5.4", + "binden": "^2.0.4" + }, + "devDependencies": { + "@binden/commitlint-config": "^2.1.0", + "@binden/semantic-release-config": "^3.0.2", + "@binden/tsconfig": "^3.0.0", + "c8": "^10.1.3", + "eslint-config-binden-ts": "^5.0.2", + "husky": "^9.1.7", + "lint-staged": "^16.2.7", + "prettier": "^3.6.2", + "ts-node": "^10.9.2", + "typedoc": "^0.28.6" + }, + "bugs": { + "url": "https://github.com/binden-js/multipart/issues" + }, + "homepage": "https://github.com/binden-js/multipart#readme", + "publishConfig": { + "access": "public" + }, + "funding": { + "type": "Coinbase Commerce", + "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + } +} diff --git a/packages/multipart/test/multipart.test.ts b/packages/multipart/test/multipart.test.ts new file mode 100644 index 00000000..157ff916 --- /dev/null +++ b/packages/multipart/test/multipart.test.ts @@ -0,0 +1,396 @@ +/* eslint-disable init-declarations, @typescript-eslint/no-floating-promises, sort-imports */ +import { deepEqual, ok } from "node:assert/strict"; +import { request, Server } from "node:http"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { Binden, Context } from "binden"; +import multipart, { Multipart } from "../index.js"; + +const port = 8080; +const url = `http://localhost:${port}/`; + +describe("Multipart", () => { + let app: Binden; + let server: Server; + + beforeEach(async () => { + app = new Binden(); + await new Promise((resolve) => { + server = app.createServer().listen(port, resolve); + }); + }); + + it("Skips when method is `GET`", async () => { + const message = { success: true }; + const assert = async (ct: Context): Promise => { + ok(ct.request.method === "GET"); + ok(typeof ct.request.body === "undefined"); + await ct.json(message); + }; + + app.use(new Multipart(), assert); + + const response = await fetch(url); + const data = (await response.json()) as unknown; + deepEqual(response.status, 200); + deepEqual(data, message); + }); + + it("Skips when method is `HEAD`", async () => { + const assert = async (ct: Context): Promise => { + ok(ct.request.method === "HEAD"); + ok(typeof ct.request.body === "undefined"); + await ct.send(); + }; + + app.use(new Multipart(), assert); + + const response = await fetch(url, { method: "HEAD" }); + const data = (await response.text()) as unknown; + deepEqual(response.status, 200); + deepEqual(data, ""); + }); + + it("Skips when no `Content-Type` header is present", async () => { + const body = "Hello World"; + const assert = async (ct: Context): Promise => { + ok(typeof ct.request.headers["content-type"] === "undefined"); + ok(typeof ct.request.body === "undefined"); + await ct.send(); + }; + + app.use(multipart(), assert); + + await new Promise((resolve, reject) => { + request(url, { method: "POST" }) + .on("response", (response) => { + const chunks: Buffer[] = []; + response + .on("data", (chunk: Buffer) => { + chunks.push(chunk); + }) + .on("error", reject) + .on("end", () => { + try { + deepEqual(Buffer.concat(chunks).toString(), ""); + ok(response.statusCode === 200); + resolve(); + } catch (error) { + reject(error as Error); + } + }); + }) + .on("error", reject) + .end(body); + }); + }); + + it("Skips when `Content-Type` is not `multipart/form-data`", async () => { + const body = "Hello World"; + const ct = "image/jpeg"; + const assert = async (context: Context): Promise => { + deepEqual(context.request.headers["content-type"], ct); + ok(typeof context.request.body === "undefined"); + await context.send(); + }; + + app.use(multipart(), assert); + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": ct }, + body, + }); + const data = (await response.text()) as unknown; + deepEqual(response.status, 200); + deepEqual(data, ""); + }); + + it("Skips when `multipart/form-data` is missing `boundary`", async () => { + const body = "Hello World"; + const form_data = "multipart/form-data"; + const assert = async (ct: Context): Promise => { + deepEqual(ct.request.headers["content-type"], form_data); + ok(typeof ct.request.body === "undefined"); + await ct.send(); + }; + + app.use(multipart(), assert); + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": form_data }, + body, + }); + const data = (await response.text()) as unknown; + deepEqual(response.status, 200); + deepEqual(data, ""); + }); + + it("Parses body as `FormData`", async () => { + const msg = { success: true }; + const body = new FormData(); + body.append("name1", "value11"); + body.append("name1", "value12"); + body.append("name2", "value2"); + const file11 = new File( + [JSON.stringify({ hello: "world" })], + "file11.json", + { type: "application/json" }, + ); + const file12 = new File([""], "file12.html", { + type: "plain/html", + }); + const file2 = new File(["Hello World"], "file2.txt", { + type: "plain/txt", + }); + body.append("file1", file11); + body.append("file1", file12); + body.append("file2", file2); + + const assert = async (context: Context): Promise => { + deepEqual(context.request.content_type?.type, "multipart/form-data"); + ok(context.request.content_type.boundary !== null); + const { body: actual } = context.request; + ok(actual instanceof FormData); + deepEqual(actual, body); + for (const key in actual.keys()) { + deepEqual(actual.getAll(key), body.getAll(key)); + } + const [actual_file11, actual_file12] = actual.getAll("file1") as File[]; + deepEqual(actual_file11.name, file11.name); + deepEqual(actual_file11.size, file11.size); + deepEqual(actual_file11.type, file11.type); + deepEqual(actual_file12.name, file12.name); + deepEqual(actual_file12.type, file12.type); + deepEqual(actual_file12.size, file12.size); + const [files2] = actual.getAll("file2") as File[]; + deepEqual(files2.name, file2.name); + deepEqual(files2.type, file2.type); + deepEqual(files2.size, file2.size); + await context.json(msg); + }; + + app.use(multipart(), assert); + + const response = await fetch(url, { method: "POST", body }); + const data = (await response.json()) as unknown; + deepEqual(response.status, 200); + deepEqual(data, msg); + }); + + it("Replies with 415 error when request body is not a valid FormData", async () => { + const ct = "multipart/form-data; boundary=----formdata-undici-064882379910"; + + app.use(multipart()); + + const response = await fetch(url, { + method: "POST", + body: "Hello World", + headers: { "Content-Type": ct }, + }); + const data = await response.text(); + deepEqual(response.status, 415); + deepEqual(data, ""); + }); + + it("Replies with 415 error when FormData reaches the `fields` limit (by default)", async () => { + const body = new FormData(); + body.append("k1", "v1"); + body.append("k2", "v2"); + + app.use(multipart({ limits: { fields: 1 } })); + + const response = await fetch(url, { method: "POST", body }); + await response.text(); + deepEqual(response.status, 415); + }); + + it("Passes an incomplete FormData when it reaches the `fields` limit (`throw_limits = true`)", async () => { + const body = new FormData(); + body.append("k1", "v1"); + body.append("k2", "v2"); + + const assert = (context: Context): Promise => { + ok(context.request.body instanceof FormData); + deepEqual(context.request.body.getAll("k1"), ["v1"]); + ok(context.request.body.get("k2") === null); + return context.send(); + }; + + app.use( + new Multipart({ config: { limits: { fields: 1 } }, throw_limits: false }), + assert, + ); + + const response = await fetch(url, { method: "POST", body }); + await response.text(); + deepEqual(response.status, 200); + }); + + it("Replies with 415 error when FormData reaches the `files` limit (by default)", async () => { + const body = new FormData(); + body.append("file1", new File([Buffer.allocUnsafe(50)], "file1.txt")); + body.append("file2", new File([Buffer.allocUnsafe(150)], "file2.txt")); + + app.use(multipart({ limits: { files: 1 } })); + + const response = await fetch(url, { method: "POST", body }); + await response.text(); + deepEqual(response.status, 415); + }); + + it("Passes an incomplete FormData when it reaches the `files` limit (`throw_limits = true`)", async () => { + const body = new FormData(); + const file1 = new File([Buffer.allocUnsafe(50)], "file1.txt", { + type: "application/octet-stream", + }); + const file2 = new File([Buffer.allocUnsafe(150)], "file2.txt", { + type: "application/octet-stream", + }); + body.append("file1", file1); + body.append("file2", file2); + + const assert = (context: Context): Promise => { + ok(context.request.body instanceof FormData); + + const actual_file1 = context.request.body.get("file1"); + const actual_file2 = context.request.body.get("file2"); + + ok(actual_file1 instanceof File); + ok(actual_file2 === null); + + deepEqual(actual_file1.name, file1.name); + deepEqual(actual_file1.size, file1.size); + deepEqual(actual_file1.type, file1.type); + + return context.send(); + }; + + app.use( + new Multipart({ config: { limits: { files: 1 } }, throw_limits: false }), + assert, + ); + + const response = await fetch(url, { method: "POST", body }); + await response.text(); + deepEqual(response.status, 200); + }); + + it("Replies with 415 error when FormData reaches the `fileSize` limit (by default)", async () => { + const body = new FormData(); + body.append("file1", new File([Buffer.allocUnsafe(50)], "file1.txt")); + body.append("file2", new File([Buffer.allocUnsafe(150)], "file2.txt")); + + app.use(multipart({ limits: { fileSize: 100 } })); + + const response = await fetch(url, { method: "POST", body }); + await response.text(); + deepEqual(response.status, 415); + }); + + it("Passes an incomplete FormData when it reaches the `fileSize` limit (`throw_limits = true`)", async () => { + const body = new FormData(); + const file1 = new File([Buffer.allocUnsafe(50)], "file1.txt", { + type: "application/octet-stream", + }); + const file2 = new File([Buffer.allocUnsafe(150)], "file2.txt", { + type: "application/octet-stream", + }); + body.append("file1", file1); + body.append("file2", file2); + + const assert = (context: Context): Promise => { + ok(context.request.body instanceof FormData); + + const actual_file1 = context.request.body.get("file1"); + const actual_file2 = context.request.body.get("file2"); + + ok(actual_file1 instanceof File); + ok(actual_file2 instanceof File); + + deepEqual(actual_file1.name, file1.name); + deepEqual(actual_file1.size, file1.size); + deepEqual(actual_file1.type, file1.type); + + deepEqual(actual_file2.name, file2.name); + deepEqual(actual_file2.size, 100); + deepEqual(actual_file2.type, file2.type); + + return context.send(); + }; + + app.use( + new Multipart({ + config: { limits: { fileSize: 100 } }, + throw_limits: false, + }), + assert, + ); + + const response = await fetch(url, { method: "POST", body }); + await response.text(); + deepEqual(response.status, 200); + }); + + it("Replies with 415 error when FormData reaches the `parts` limit (by default)", async () => { + const body = new FormData(); + const file = new File(["123"], "file.txt", { + type: "plain/txt", + }); + body.append("file", file); + body.append("k1", "v1"); + body.append("k2", "v2"); + + app.use(multipart({ limits: { parts: 2 } })); + + const response = await fetch(url, { method: "POST", body }); + await response.text(); + deepEqual(response.status, 415); + }); + + it("Passes an incomplete FormData when it reaches the `parts` limit (`throw_limits = true`)", async () => { + const body = new FormData(); + const file = new File(["123"], "file.txt", { + type: "plain/txt", + }); + body.append("file", file); + body.append("k1", "v1"); + body.append("k2", "v2"); + + const assert = (context: Context): Promise => { + ok(context.request.body instanceof FormData); + + const actual_file = context.request.body.get("file"); + ok(actual_file instanceof File); + deepEqual(actual_file.name, file.name); + deepEqual(actual_file.size, file.size); + deepEqual(actual_file.type, file.type); + deepEqual(context.request.body.get("k1"), "v1"); + deepEqual(context.request.body.get("k2"), null); + + return context.send(); + }; + + app.use( + new Multipart({ config: { limits: { parts: 2 } }, throw_limits: false }), + assert, + ); + + const response = await fetch(url, { method: "POST", body }); + await response.text(); + deepEqual(response.status, 200); + }); + + afterEach(async () => { + server.closeIdleConnections(); + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + }); +}); From 4de80903336112e2abec05ff21199dea9bef4aa8 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 17:07:10 +0300 Subject: [PATCH 003/675] dependencies: bump pino from 9.5.0 to 10.3.1 in @binden/logger Co-Authored-By: Claude Opus 5.5 --- packages/logger/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/logger/package.json b/packages/logger/package.json index 85b513f0..88048213 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -45,7 +45,7 @@ }, "homepage": "https://github.com/binden-js/logger#readme", "dependencies": { - "pino": "^9.5.0" + "pino": "^10.3.1" }, "devDependencies": { "@binden/commitlint-config": "^2.1.0", From e96215f85b629e53fd86feb44be746bbba0b4947 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 17:07:11 +0300 Subject: [PATCH 004/675] metadata!: change the @binden/logger license to MIT Co-Authored-By: Claude Opus 5.5 --- packages/logger/LICENSE | 682 ++--------------------------------- packages/logger/package.json | 2 +- 2 files changed, 22 insertions(+), 662 deletions(-) diff --git a/packages/logger/LICENSE b/packages/logger/LICENSE index 162676cb..d542516c 100644 --- a/packages/logger/LICENSE +++ b/packages/logger/LICENSE @@ -1,661 +1,21 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - -Copyright (C) 2007 Free Software Foundation, Inc. -Everyone is permitted to copy and distribute verbatim copies -of this license document, but changing it is not allowed. - - Preamble - -The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - -The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - -When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - -Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - -A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - -The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - -An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - -The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - -0. Definitions. - -"This License" refers to version 3 of the GNU Affero General Public License. - -"Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - -"The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - -To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - -A "covered work" means either the unmodified Program or a work based -on the Program. - -To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - -To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - -An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - -1. Source Code. - -The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - -A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - -The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - -The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - -The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - -The Corresponding Source for a work in source code form is that -same work. - -2. Basic Permissions. - -All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - -You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - -Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - -3. Protecting Users' Legal Rights From Anti-Circumvention Law. - -No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - -When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - -4. Conveying Verbatim Copies. - -You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - -You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - -5. Conveying Modified Source Versions. - -You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - -A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - -6. Conveying Non-Source Forms. - -You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - -A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - -A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - -"Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - -If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - -The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - -Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - -7. Additional Terms. - -"Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - -When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - -Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - -All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - -If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - -Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - -8. Termination. - -You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - -However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - -Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - -Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - -9. Acceptance Not Required for Having Copies. - -You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - -10. Automatic Licensing of Downstream Recipients. - -Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - -An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - -You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - -11. Patents. - -A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - -A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - -Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - -In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - -If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - -If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - -A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - -Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - -12. No Surrender of Others' Freedom. - -If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - -13. Remote Network Interaction; Use with the GNU General Public License. - -Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - -Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - -14. Revised Versions of this License. - -The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - -If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - -Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - -15. Disclaimer of Warranty. - -THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. Limitation of Liability. - -IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - -17. Interpretation of Sections 15 and 16. - -If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - -If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - -To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - -If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - -You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. +MIT License + +Copyright (c) 2026 Sergey Bakulin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/logger/package.json b/packages/logger/package.json index 88048213..5b14fd65 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -36,7 +36,7 @@ "logger" ], "author": "Sergey Bakulin ", - "license": "AGPL-3.0-only", + "license": "MIT", "directories": { "doc": "docs" }, From 5ebec07fadca6311f6452a998332339a1162a3cb Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 17:07:37 +0300 Subject: [PATCH 005/675] build!: release all packages in lockstep from npm workspaces, require Node.js >=24.14.1 Turn the repository into npm workspaces (`packages/*`) with a single lockfile and shared tooling configured in place (TypeScript, ESLint, Prettier, commitlint, c8, TypeDoc and semantic-release) instead of the `@binden/*-config` packages. All packages are now released together with the same version and depend on each other with `^` (see `scripts/release.mjs`). Node.js `>=24.14.1` and npm `>=11.11.0` are required. Co-Authored-By: Claude Opus 5.5 --- .c8rc.json | 10 + .gitignore | 21 + .husky/commit-msg | 1 + {packages/binden/.husky => .husky}/pre-commit | 0 .lintstagedrc.json | 4 + .prettierignore | 2 + .releaserc.json | 165 + CHANGELOG.md | 1 + .../CODE_OF_CONDUCT.md => CODE_OF_CONDUCT.md | 0 LICENSE | 21 + commitlint.config.js | 30 + eslint.config.js | 301 + .../package-lock.json => package-lock.json | 11670 +++++-------- package.json | 60 + packages/binden/.commitlintrc.json | 1 - packages/binden/.eslintignore | 5 - packages/binden/.gitignore | 104 - packages/binden/.husky/.gitignore | 1 - packages/binden/.husky/commit-msg | 1 - packages/binden/.lintstagedrc.json | 4 - packages/binden/.mocharc.json | 8 - packages/binden/.npmignore | 52 - packages/binden/.prettierignore | 2 - packages/binden/.releaserc.json | 1 - packages/binden/docs/.nojekyll | 1 - packages/binden/docs/assets/hierarchy.js | 2 - packages/binden/docs/assets/highlight.css | 146 - packages/binden/docs/assets/icons.js | 21 - packages/binden/docs/assets/icons.svg | 1 - packages/binden/docs/assets/main.js | 2399 --- packages/binden/docs/assets/navigation.js | 2 - packages/binden/docs/assets/search.js | 2 - packages/binden/docs/assets/style.css | 1602 -- .../binden/docs/classes/AcceptEncoding.html | 742 - .../binden/docs/classes/Authorization.html | 796 - packages/binden/docs/classes/Binden.html | 1055 -- packages/binden/docs/classes/BindenError.html | 997 -- .../binden/docs/classes/BindenRequest.html | 14562 ---------------- .../binden/docs/classes/BindenResponse.html | 12943 -------------- .../binden/docs/classes/ContentEncoding.html | 753 - .../binden/docs/classes/ContentRange.html | 849 - packages/binden/docs/classes/ContentType.html | 752 - packages/binden/docs/classes/Context.html | 1556 -- packages/binden/docs/classes/Cookie.html | 1085 -- packages/binden/docs/classes/Forwarded.html | 857 - .../binden/docs/classes/IfModifiedSince.html | 644 - packages/binden/docs/classes/Middleware.html | 588 - packages/binden/docs/classes/Range.html | 753 - packages/binden/docs/classes/Router.html | 1438 -- packages/binden/docs/hierarchy.html | 418 - packages/binden/docs/index.html | 895 - .../docs/interfaces/IAcceptEncoding.html | 407 - .../docs/interfaces/IAuthorization.html | 402 - .../binden/docs/interfaces/IBaseContext.html | 417 - .../docs/interfaces/IBindenErrorOptions.html | 493 - .../docs/interfaces/IBindenOptions.html | 412 - .../docs/interfaces/IContentEncoding.html | 357 - .../binden/docs/interfaces/IContentRange.html | 477 - .../binden/docs/interfaces/IContentType.html | 441 - packages/binden/docs/interfaces/ICookie.html | 724 - .../binden/docs/interfaces/IForwarded.html | 528 - .../docs/interfaces/IIfModifiedSince.html | 345 - .../docs/interfaces/IMiddlewareParams.html | 385 - packages/binden/docs/interfaces/IRange.html | 415 - .../docs/interfaces/IRouterOptions.html | 336 - packages/binden/docs/modules.html | 1190 -- .../binden/docs/types/IAcceptEncodings.html | 246 - .../docs/types/IAuthenticationType.html | 248 - packages/binden/docs/types/IEncodings.html | 235 - .../docs/types/IFunctionMiddleware.html | 280 - packages/binden/docs/types/IHeaders.html | 235 - packages/binden/docs/types/IHeadersValue.html | 233 - packages/binden/docs/types/IMiddleware.html | 237 - packages/binden/docs/types/IProtocol.html | 229 - packages/binden/docs/types/IStack.html | 239 - packages/binden/docs/types/IStackItem.html | 237 - packages/binden/docs/types/IUnit.html | 227 - .../docs/variables/DefaultErrorCode.html | 227 - packages/binden/docs/variables/ct_form.html | 229 - packages/binden/docs/variables/ct_html.html | 227 - packages/binden/docs/variables/ct_json.html | 227 - packages/binden/docs/variables/ct_text.html | 227 - packages/binden/eslint.config.js | 5 - packages/binden/package.json | 61 +- packages/binden/src/binden.ts | 2 +- packages/binden/src/context.ts | 2 +- .../binden/src/headers/accept-encoding.ts | 8 +- packages/binden/src/headers/content-range.ts | 2 +- packages/binden/src/request.ts | 2 +- packages/binden/src/router.ts | 8 +- packages/binden/tsconfig.json | 4 +- packages/binden/typedoc.json | 8 - packages/body/index.ts | 2 +- packages/body/package.json | 58 +- packages/body/tsconfig.json | 3 + packages/compression/package.json | 50 +- packages/compression/tsconfig.json | 3 + packages/logger/index.ts | 12 +- packages/logger/package.json | 50 +- packages/logger/tsconfig.json | 3 + packages/multipart/index.ts | 7 +- packages/multipart/package.json | 65 +- packages/multipart/tsconfig.json | 3 + scripts/release.mjs | 163 + tsconfig.base.json | 24 + typedoc.json | 11 + 106 files changed, 5589 insertions(+), 64403 deletions(-) create mode 100644 .c8rc.json create mode 100644 .gitignore create mode 100644 .husky/commit-msg rename {packages/binden/.husky => .husky}/pre-commit (100%) mode change 100755 => 100644 create mode 100644 .lintstagedrc.json create mode 100644 .prettierignore create mode 100644 .releaserc.json create mode 100644 CHANGELOG.md rename packages/binden/CODE_OF_CONDUCT.md => CODE_OF_CONDUCT.md (100%) create mode 100644 LICENSE create mode 100644 commitlint.config.js create mode 100644 eslint.config.js rename packages/binden/package-lock.json => package-lock.json (60%) create mode 100644 package.json delete mode 100644 packages/binden/.commitlintrc.json delete mode 100644 packages/binden/.eslintignore delete mode 100644 packages/binden/.gitignore delete mode 100644 packages/binden/.husky/.gitignore delete mode 100755 packages/binden/.husky/commit-msg delete mode 100644 packages/binden/.lintstagedrc.json delete mode 100644 packages/binden/.mocharc.json delete mode 100644 packages/binden/.npmignore delete mode 100644 packages/binden/.prettierignore delete mode 100644 packages/binden/.releaserc.json delete mode 100644 packages/binden/docs/.nojekyll delete mode 100644 packages/binden/docs/assets/hierarchy.js delete mode 100644 packages/binden/docs/assets/highlight.css delete mode 100644 packages/binden/docs/assets/icons.js delete mode 100644 packages/binden/docs/assets/icons.svg delete mode 100644 packages/binden/docs/assets/main.js delete mode 100644 packages/binden/docs/assets/navigation.js delete mode 100644 packages/binden/docs/assets/search.js delete mode 100644 packages/binden/docs/assets/style.css delete mode 100644 packages/binden/docs/classes/AcceptEncoding.html delete mode 100644 packages/binden/docs/classes/Authorization.html delete mode 100644 packages/binden/docs/classes/Binden.html delete mode 100644 packages/binden/docs/classes/BindenError.html delete mode 100644 packages/binden/docs/classes/BindenRequest.html delete mode 100644 packages/binden/docs/classes/BindenResponse.html delete mode 100644 packages/binden/docs/classes/ContentEncoding.html delete mode 100644 packages/binden/docs/classes/ContentRange.html delete mode 100644 packages/binden/docs/classes/ContentType.html delete mode 100644 packages/binden/docs/classes/Context.html delete mode 100644 packages/binden/docs/classes/Cookie.html delete mode 100644 packages/binden/docs/classes/Forwarded.html delete mode 100644 packages/binden/docs/classes/IfModifiedSince.html delete mode 100644 packages/binden/docs/classes/Middleware.html delete mode 100644 packages/binden/docs/classes/Range.html delete mode 100644 packages/binden/docs/classes/Router.html delete mode 100644 packages/binden/docs/hierarchy.html delete mode 100644 packages/binden/docs/index.html delete mode 100644 packages/binden/docs/interfaces/IAcceptEncoding.html delete mode 100644 packages/binden/docs/interfaces/IAuthorization.html delete mode 100644 packages/binden/docs/interfaces/IBaseContext.html delete mode 100644 packages/binden/docs/interfaces/IBindenErrorOptions.html delete mode 100644 packages/binden/docs/interfaces/IBindenOptions.html delete mode 100644 packages/binden/docs/interfaces/IContentEncoding.html delete mode 100644 packages/binden/docs/interfaces/IContentRange.html delete mode 100644 packages/binden/docs/interfaces/IContentType.html delete mode 100644 packages/binden/docs/interfaces/ICookie.html delete mode 100644 packages/binden/docs/interfaces/IForwarded.html delete mode 100644 packages/binden/docs/interfaces/IIfModifiedSince.html delete mode 100644 packages/binden/docs/interfaces/IMiddlewareParams.html delete mode 100644 packages/binden/docs/interfaces/IRange.html delete mode 100644 packages/binden/docs/interfaces/IRouterOptions.html delete mode 100644 packages/binden/docs/modules.html delete mode 100644 packages/binden/docs/types/IAcceptEncodings.html delete mode 100644 packages/binden/docs/types/IAuthenticationType.html delete mode 100644 packages/binden/docs/types/IEncodings.html delete mode 100644 packages/binden/docs/types/IFunctionMiddleware.html delete mode 100644 packages/binden/docs/types/IHeaders.html delete mode 100644 packages/binden/docs/types/IHeadersValue.html delete mode 100644 packages/binden/docs/types/IMiddleware.html delete mode 100644 packages/binden/docs/types/IProtocol.html delete mode 100644 packages/binden/docs/types/IStack.html delete mode 100644 packages/binden/docs/types/IStackItem.html delete mode 100644 packages/binden/docs/types/IUnit.html delete mode 100644 packages/binden/docs/variables/DefaultErrorCode.html delete mode 100644 packages/binden/docs/variables/ct_form.html delete mode 100644 packages/binden/docs/variables/ct_html.html delete mode 100644 packages/binden/docs/variables/ct_json.html delete mode 100644 packages/binden/docs/variables/ct_text.html delete mode 100644 packages/binden/eslint.config.js delete mode 100644 packages/binden/typedoc.json create mode 100644 packages/body/tsconfig.json create mode 100644 packages/compression/tsconfig.json create mode 100644 packages/logger/tsconfig.json create mode 100644 packages/multipart/tsconfig.json create mode 100644 scripts/release.mjs create mode 100644 tsconfig.base.json create mode 100644 typedoc.json diff --git a/.c8rc.json b/.c8rc.json new file mode 100644 index 00000000..05f530ba --- /dev/null +++ b/.c8rc.json @@ -0,0 +1,10 @@ +{ + "all": true, + "include": ["packages/*/index.ts", "packages/*/src/**/*.ts"], + "reporter": ["lcov", "text"], + "check-coverage": true, + "lines": 100, + "branches": 100, + "functions": 100, + "statements": 100 +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..5eee8879 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +*.tsbuildinfo + +# Coverage (c8) +coverage/ + +# API documentation (TypeDoc), deployed by CI +docs/ + +# Output of `npm pack` +*.tgz + +# Logs +*.log + +# OS files +.DS_Store diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 00000000..da994831 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +npx --no -- commitlint --edit "$1" diff --git a/packages/binden/.husky/pre-commit b/.husky/pre-commit old mode 100755 new mode 100644 similarity index 100% rename from packages/binden/.husky/pre-commit rename to .husky/pre-commit diff --git a/.lintstagedrc.json b/.lintstagedrc.json new file mode 100644 index 00000000..e0b2b2c9 --- /dev/null +++ b/.lintstagedrc.json @@ -0,0 +1,4 @@ +{ + "*.{js,mjs,ts}": ["eslint --fix", "prettier -w"], + "!(*.{js,mjs,ts})": "prettier -w --ignore-unknown" +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..7979d0d2 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# Generated by semantic-release (other ignores come from .gitignore) +/CHANGELOG.md diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 00000000..70425f2f --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,165 @@ +{ + "branches": ["main"], + "plugins": [ + [ + "@semantic-release/commit-analyzer", + { + "preset": "conventionalcommits", + "releaseRules": [ + { + "breaking": true, + "release": "major" + }, + { + "type": "feat", + "release": "minor" + }, + { + "type": "fix", + "release": "patch" + }, + { + "type": "perf", + "release": "patch" + }, + { + "type": "revert", + "release": "patch" + }, + { + "type": "build", + "release": "patch" + }, + { + "type": "initial", + "release": "minor" + }, + { + "type": "dependencies", + "release": "patch" + }, + { + "type": "peerDependencies", + "release": "patch" + } + ] + } + ], + [ + "@semantic-release/release-notes-generator", + { + "preset": "conventionalcommits", + "presetConfig": { + "types": [ + { + "type": "feat", + "section": "Features", + "hidden": false + }, + { + "type": "fix", + "section": "Bug Fixes", + "hidden": false + }, + { + "type": "perf", + "section": "Performance Improvements", + "hidden": false + }, + { + "type": "revert", + "section": "Reverts", + "hidden": false + }, + { + "type": "build", + "section": "Build System", + "hidden": false + }, + { + "type": "initial", + "section": "Initial", + "hidden": false + }, + { + "type": "dependencies", + "section": "Dependencies", + "hidden": false + }, + { + "type": "peerdependencies", + "section": "Peer dependencies", + "hidden": false + }, + { + "type": "devDependencies", + "section": "Dev dependencies", + "hidden": true + }, + { + "type": "metadata", + "section": "Metadata", + "hidden": true + }, + { + "type": "docs", + "section": "Documentation", + "hidden": true + }, + { + "type": "style", + "section": "Styles", + "hidden": true + }, + { + "type": "chore", + "section": "Miscellaneous Chores", + "hidden": true + }, + { + "type": "refactor", + "section": "Code Refactoring", + "hidden": true + }, + { + "type": "test", + "section": "Tests", + "hidden": true + }, + { + "type": "ci", + "section": "Continuous Integration", + "hidden": true + } + ] + } + } + ], + [ + "@semantic-release/changelog", + { + "changelogTitle": "# Changelog" + } + ], + [ + "@semantic-release/exec", + { + "verifyReleaseCmd": "node scripts/release.mjs verify ${nextRelease.version}", + "prepareCmd": "node scripts/release.mjs version ${nextRelease.version}", + "publishCmd": "node scripts/release.mjs publish" + } + ], + "@semantic-release/github", + [ + "@semantic-release/git", + { + "assets": [ + "CHANGELOG.md", + "package-lock.json", + "packages/*/package.json" + ], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ] + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..825c32f0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/packages/binden/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md similarity index 100% rename from packages/binden/CODE_OF_CONDUCT.md rename to CODE_OF_CONDUCT.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..d542516c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sergey Bakulin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 00000000..bc07baf1 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,30 @@ +export default { + extends: ["@commitlint/config-conventional"], + rules: { + "body-max-line-length": [1, "always", 100], + "footer-max-line-length": [1, "always", 100], + "type-case": [2, "always", "camel-case"], + "type-enum": [ + 2, + "always", + [ + "feat", + "fix", + "perf", + "revert", + "build", + "initial", + "dependencies", + "peerDependencies", + "devDependencies", + "metadata", + "docs", + "style", + "chore", + "refactor", + "test", + "ci", + ], + ], + }, +}; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..2b22bf25 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,301 @@ +import globals from "globals"; +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; + +// Rules are inlined from the former shared configs +// @binden/eslint-config@7.0.1 and @binden/eslint-config-ts@6.0.2. +export default [ + { + name: "binden/ignores", + ignores: ["**/dist/", "coverage/", "docs/"], + }, + js.configs.recommended, + { + name: "binden/javascript", + rules: { + "array-callback-return": [ + "error", + { allowImplicit: true, checkForEach: true }, + ], + "no-constant-binary-expression": "warn", + "no-constructor-return": "error", + "no-duplicate-imports": "error", + "no-promise-executor-return": "error", + "no-self-compare": "error", + "no-template-curly-in-string": "error", + "no-unmodified-loop-condition": "warn", + "no-unreachable-loop": "error", + "no-use-before-define": "error", + "require-atomic-updates": "error", + "arrow-body-style": "error", + "block-scoped-var": "error", + "class-methods-use-this": "warn", + "consistent-return": "error", + curly: "error", + "default-case": "error", + "default-case-last": "error", + "default-param-last": "error", + "dot-notation": "error", + eqeqeq: "error", + "func-name-matching": "error", + "func-names": ["error", "as-needed"], + "func-style": ["error", "declaration", { allowArrowFunctions: true }], + "grouped-accessor-pairs": "error", + "init-declarations": "warn", + "new-cap": "warn", + "no-alert": "warn", + "no-array-constructor": "error", + "no-caller": "error", + "no-confusing-arrow": "error", + "no-console": "error", + "no-continue": "error", + "no-div-regex": "error", + "no-else-return": "error", + "no-empty-function": "error", + "no-eq-null": "error", + "no-eval": "error", + "no-extend-native": "error", + "no-extra-bind": "error", + "no-extra-label": "error", + "no-floating-decimal": "error", + "no-implicit-coercion": "error", + "no-implied-eval": "error", + "no-inline-comments": "error", + "no-invalid-this": "error", + "no-iterator": "error", + "no-label-var": "error", + "no-labels": "error", + "no-lone-blocks": "error", + "no-lonely-if": "error", + "no-loop-func": "error", + "no-multi-assign": "error", + "no-multi-str": "error", + "no-negated-condition": "warn", + "no-new": "error", + "no-new-func": "error", + "no-new-object": "error", + "no-new-wrappers": "error", + "no-octal-escape": "error", + "no-param-reassign": "error", + "no-plusplus": "error", + "no-proto": "error", + "no-return-assign": "error", + "no-return-await": "error", + "no-script-url": "error", + "no-sequences": "error", + "no-shadow": "error", + "no-throw-literal": "error", + "no-undef-init": "error", + "no-undefined": "error", + "no-underscore-dangle": [ + "error", + { enforceInMethodNames: true, enforceInClassFields: true }, + ], + "no-unneeded-ternary": "error", + "no-unused-expressions": "error", + "no-useless-call": "error", + "no-useless-computed-key": "error", + "no-useless-concat": "error", + "no-useless-constructor": "error", + "no-useless-rename": "error", + "no-var": "error", + "no-void": "error", + "no-warning-comments": "error", + "object-shorthand": "error", + "one-var": ["error", "never"], + "one-var-declaration-per-line": ["error", "always"], + "operator-assignment": "error", + "prefer-arrow-callback": "error", + "prefer-const": "error", + "prefer-destructuring": "error", + "prefer-exponentiation-operator": "error", + "prefer-named-capture-group": "error", + "prefer-numeric-literals": "error", + "prefer-object-has-own": "error", + "prefer-object-spread": "error", + "prefer-promise-reject-errors": "error", + "prefer-rest-params": "error", + "prefer-spread": "error", + "prefer-template": "error", + "quote-props": ["error", "as-needed"], + radix: "error", + "require-await": "error", + "require-unicode-regexp": "error", + "sort-imports": "error", + "sort-vars": "error", + "spaced-comment": ["error", "always"], + strict: ["error", "never"], + "symbol-description": "error", + "vars-on-top": "error", + yoda: ["error", "never"], + "eol-last": "error", + "line-comment-position": ["warn", { position: "above" }], + "linebreak-style": ["error", "unix"], + "no-dupe-class-members": "error", + "no-extra-semi": "error", + "no-inner-declarations": "error", + "no-mixed-spaces-and-tabs": "error", + "no-new-symbol": "error", + "no-redeclare": "error", + }, + }, + ...tseslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + ...tseslint.configs.strict, + { + name: "binden/typescript", + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + "@typescript-eslint/array-type": ["error", { default: "array" }], + "@typescript-eslint/ban-tslint-comment": "error", + "@typescript-eslint/class-literal-property-style": ["error", "getters"], + "@typescript-eslint/consistent-generic-constructors": [ + "error", + "constructor", + ], + "@typescript-eslint/consistent-indexed-object-style": ["error", "record"], + "@typescript-eslint/consistent-type-definitions": ["error", "interface"], + "@typescript-eslint/consistent-type-exports": [ + "error", + { fixMixedExportsWithInlineTypeSpecifier: false }, + ], + "@typescript-eslint/explicit-function-return-type": "error", + "@typescript-eslint/explicit-member-accessibility": "error", + "@typescript-eslint/explicit-module-boundary-types": "error", + "@typescript-eslint/method-signature-style": ["error", "method"], + "@typescript-eslint/no-base-to-string": "error", + "@typescript-eslint/no-confusing-void-expression": "error", + "@typescript-eslint/no-duplicate-enum-values": "error", + "@typescript-eslint/no-dynamic-delete": "error", + "@typescript-eslint/no-empty-object-type": [ + "error", + { allowInterfaces: "with-single-extends" }, + ], + "@typescript-eslint/no-extraneous-class": "error", + "@typescript-eslint/no-invalid-void-type": "error", + "@typescript-eslint/no-meaningless-void-operator": "error", + "@typescript-eslint/no-non-null-asserted-nullish-coalescing": "error", + "@typescript-eslint/no-redundant-type-constituents": "error", + "@typescript-eslint/no-require-imports": "error", + "@typescript-eslint/no-unnecessary-boolean-literal-compare": "error", + "@typescript-eslint/no-unnecessary-condition": "error", + "@typescript-eslint/no-unnecessary-qualifier": "error", + "@typescript-eslint/no-unnecessary-type-arguments": "error", + "@typescript-eslint/no-useless-empty-export": "error", + "@typescript-eslint/prefer-enum-initializers": "error", + "@typescript-eslint/prefer-for-of": "error", + "@typescript-eslint/prefer-function-type": "error", + "@typescript-eslint/prefer-includes": "error", + "@typescript-eslint/prefer-literal-enum-member": "error", + "@typescript-eslint/prefer-nullish-coalescing": "error", + "@typescript-eslint/prefer-optional-chain": "error", + "@typescript-eslint/prefer-readonly": "error", + "@typescript-eslint/prefer-regexp-exec": "error", + "@typescript-eslint/prefer-ts-expect-error": "error", + "@typescript-eslint/require-array-sort-compare": "error", + "@typescript-eslint/strict-boolean-expressions": "error", + "@typescript-eslint/switch-exhaustiveness-check": "error", + "@typescript-eslint/unified-signatures": [ + "warn", + { ignoreDifferentlyNamedParameters: true }, + ], + "default-param-last": "off", + "@typescript-eslint/default-param-last": "error", + "dot-notation": "off", + "@typescript-eslint/dot-notation": "error", + "no-dupe-class-members": "off", + "@typescript-eslint/no-dupe-class-members": "error", + "no-invalid-this": "off", + "@typescript-eslint/no-invalid-this": "error", + "no-loop-func": "off", + "@typescript-eslint/no-loop-func": "error", + "no-redeclare": "off", + "@typescript-eslint/no-redeclare": "error", + "no-shadow": "off", + "@typescript-eslint/no-shadow": "error", + "@typescript-eslint/only-throw-error": "error", + "@typescript-eslint/no-unused-expressions": "error", + "no-use-before-define": "off", + "@typescript-eslint/no-use-before-define": "error", + "@typescript-eslint/no-useless-constructor": "error", + "no-return-await": "off", + "@typescript-eslint/return-await": "error", + }, + }, + { + name: "binden/tests", + files: ["packages/*/test/**/*.ts"], + rules: { + "@typescript-eslint/no-floating-promises": [ + "error", + { + allowForKnownSafeCalls: [ + { + from: "package", + package: "node:test", + name: ["describe", "it", "suite", "test"], + }, + ], + }, + ], + }, + }, + { + ...tseslint.configs.disableTypeChecked, + name: "binden/tooling", + files: ["**/*.{js,mjs}"], + languageOptions: { + ...tseslint.configs.disableTypeChecked.languageOptions, + globals: globals.node, + }, + rules: { + ...tseslint.configs.disableTypeChecked.rules, + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + }, + }, + { + // Code carried over unchanged from the former separate repositories predates + // the current rule set. Only infrastructure moved; clean these up separately. + name: "binden/legacy", + files: [ + "packages/binden/**/*.ts", + "packages/body/**/*.ts", + "packages/logger/**/*.ts", + ], + rules: { "sort-imports": "off" }, + }, + { + name: "binden/legacy-binden-tests", + files: ["packages/binden/test/**/*.ts"], + rules: { "@typescript-eslint/prefer-promise-reject-errors": "off" }, + }, + { + name: "binden/legacy-method-signature-style", + files: ["packages/binden/src/binden.ts"], + rules: { "@typescript-eslint/method-signature-style": "off" }, + }, + { + name: "binden/legacy-strict-boolean-expressions", + files: ["packages/binden/src/headers/forwarded.ts"], + rules: { "@typescript-eslint/strict-boolean-expressions": "off" }, + }, + { + name: "binden/legacy-switch-exhaustiveness-check", + files: [ + "packages/binden/src/headers/authorization.ts", + "packages/logger/index.ts", + ], + rules: { "@typescript-eslint/switch-exhaustiveness-check": "off" }, + }, + { + name: "binden/legacy-no-unnecessary-type-assertion", + files: ["packages/multipart/test/multipart.test.ts"], + rules: { "@typescript-eslint/no-unnecessary-type-assertion": "off" }, + }, +]; diff --git a/packages/binden/package-lock.json b/package-lock.json similarity index 60% rename from packages/binden/package-lock.json rename to package-lock.json index f2ba5ec9..a08088ec 100644 --- a/packages/binden/package-lock.json +++ b/package-lock.json @@ -1,41 +1,41 @@ { - "name": "binden", - "version": "2.0.4", + "name": "binden-monorepo", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "binden", - "version": "2.0.4", - "license": "MIT", - "dependencies": { - "@binden/logger": "^2.0.3" - }, + "name": "binden-monorepo", + "workspaces": [ + "packages/logger", + "packages/binden", + "packages/body", + "packages/compression", + "packages/multipart" + ], "devDependencies": { - "@binden/commitlint-config": "^2.1.0", - "@binden/semantic-release-config": "^3.0.4", - "@binden/tsconfig": "^2.0.0", - "@types/mocha": "^10.0.7", - "@types/sinon": "^17.0.3", - "c8": "^11.0.0", - "eslint-config-binden-ts": "^4.0.2", - "fast-json-stringify": "^7.0.1", + "@commitlint/cli": "^21.2.3", + "@commitlint/config-conventional": "^21.2.3", + "@eslint/js": "^10.0.1", + "@semantic-release/changelog": "^7.0.0", + "@semantic-release/exec": "^7.1.0", + "@semantic-release/git": "^11.0.1", + "@types/node": "^24.19.2", + "c8": "^12.0.0", + "conventional-changelog-conventionalcommits": "^9.3.1", + "eslint": "^10.12.0", + "globals": "^17.13.0", "husky": "^9.1.7", - "lint-staged": "^17.3.0", - "mocha": "^11.0.1", - "node-fetch": "^3.3.2", - "prettier": "^3.8.4", - "sinon": "^19.0.2", - "ts-node": "^10.9.2", - "typedoc": "^0.28.19" + "lint-staged": "^17.6.0", + "prettier": "^3.9.10", + "semantic-release": "^25.0.9", + "tsx": "^4.23.15", + "typedoc": "^0.28.20", + "typescript": "^6.0.3", + "typescript-eslint": "^8.71.2" }, "engines": { - "node": ">=20.11.0", - "npm": ">=10.2.4" - }, - "funding": { - "type": "Coinbase Commerce", - "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + "node": ">=24.15.0", + "npm": ">=11.11.0" } }, "node_modules/@actions/core": { @@ -44,7 +44,6 @@ "integrity": "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@actions/exec": "^3.0.0", "@actions/http-client": "^4.0.0" @@ -56,7 +55,6 @@ "integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@actions/io": "^3.0.2" } @@ -67,19 +65,17 @@ "integrity": "sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tunnel": "^0.0.6", "undici": "^6.23.0" } }, "node_modules/@actions/http-client/node_modules/undici": { - "version": "6.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", - "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18.17" } @@ -89,18 +85,16 @@ "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz", "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -109,12 +103,11 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -129,77 +122,44 @@ "node": ">=18" } }, - "node_modules/@binden/commitlint-config": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@binden/commitlint-config/-/commitlint-config-2.1.0.tgz", - "integrity": "sha512-G5Z5tZExUccNwPPDVyudNrxaB/AYEM0SNc8/4h2MiMKel3Aph0AOSW9q0zsxiNb52+ybv76YIdUS0wFbL24JNw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.11.0", - "npm": ">=10.2.4" - }, - "funding": { - "type": "Coinbase Commerce", - "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" - }, - "peerDependencies": { - "@commitlint/config-conventional": "^18.6.0", - "commitlint": "^18.6.0" - } + "node_modules/@binden/body": { + "resolved": "packages/body", + "link": true + }, + "node_modules/@binden/compression": { + "resolved": "packages/compression", + "link": true }, "node_modules/@binden/logger": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@binden/logger/-/logger-2.0.3.tgz", - "integrity": "sha512-jPaTX0wGffPNvsV6nfYAF9CwSZ91vStAJCS7G/jTrrLR7j0yLh7kHOzSPoH//DciDSkhhdkxbYqDb4jgA/JoaQ==", - "dependencies": { - "pino": "^9.5.0" - }, - "engines": { - "node": ">=20.11.0", - "npm": ">=10.2.4" - }, - "funding": { - "type": "Coinbase Commerce", - "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" - } + "resolved": "packages/logger", + "link": true }, - "node_modules/@binden/semantic-release-config": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@binden/semantic-release-config/-/semantic-release-config-3.0.4.tgz", - "integrity": "sha512-nctoU9XOLYRt8h31DI3LVbx1GMN7RPW7o6G3R6Xn2TiSj+dzkRnyi7UNUdWSZliawkaoEctjTkDfBKm2Qtp3MA==", + "node_modules/@binden/multipart": { + "resolved": "packages/multipart", + "link": true + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=24.14.1", - "npm": ">=11.11.0" - }, - "funding": { - "type": "Coinbase Commerce", - "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" - }, - "peerDependencies": { - "@semantic-release/changelog": "^6.0.3", - "@semantic-release/git": "^10.0.1", - "semantic-release": "^25.0.3" + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" } }, - "node_modules/@binden/tsconfig": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@binden/tsconfig/-/tsconfig-2.0.0.tgz", - "integrity": "sha512-SsYkxeFBXMXvma11ajvwNf/52DPXE4NEwVfl20RV+AUd7AjiInao8mnLia9OAFosa5iJir/aaXRSi9w/OtMCnA==", + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=20.11.0", - "npm": ">=10.2.4" - }, - "funding": { - "type": "Coinbase Commerce", - "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" - }, - "peerDependencies": { - "typescript": "^5.3.3" + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" } }, "node_modules/@colors/colors": { @@ -209,1391 +169,1593 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.1.90" } }, "node_modules/@commitlint/cli": { - "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-18.6.1.tgz", - "integrity": "sha512-5IDE0a+lWGdkOvKH892HHAZgbAjcj1mT5QrfA/SVbLJV/BbBMGyKN0W5mhgjekPJJwEQdVNvhl9PwUacY58Usw==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.2.3.tgz", + "integrity": "sha512-5yOX6IRjcrIReCxpyAd04hgI/yhUMpVBJ/ouwFV0wk9yx5+fYRDWZWJ5h55Tbedgfol+5m2mU5nqzbB8g3v0vQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@commitlint/format": "^18.6.1", - "@commitlint/lint": "^18.6.1", - "@commitlint/load": "^18.6.1", - "@commitlint/read": "^18.6.1", - "@commitlint/types": "^18.6.1", - "execa": "^5.0.0", - "lodash.isfunction": "^3.0.9", - "resolve-from": "5.0.0", - "resolve-global": "1.0.0", - "yargs": "^17.0.0" + "@commitlint/config-conventional": "^21.2.3", + "@commitlint/format": "^21.2.3", + "@commitlint/lint": "^21.2.3", + "@commitlint/load": "^21.2.3", + "@commitlint/read": "^21.2.3", + "@commitlint/types": "^21.2.3", + "tinyexec": "^1.0.0", + "yargs": "^18.0.0" }, "bin": { "commitlint": "cli.js" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/config-conventional": { - "version": "18.6.3", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-18.6.3.tgz", - "integrity": "sha512-8ZrRHqF6je+TRaFoJVwszwnOXb/VeYrPmTwPhf0WxpzpGTcYy1p0SPyZ2eRn/sRi/obnWAcobtDAq6+gJQQNhQ==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.2.3.tgz", + "integrity": "sha512-EP2n5DN2GwRWNVrLX0Jx3mpQlW5PfFi9HnD9OJ8hN/H0xFqpCFyHeGDhg+0P8/21iMMcdkcQgbNqmVklebqYsQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@commitlint/types": "^18.6.1", - "conventional-changelog-conventionalcommits": "^7.0.2" + "@commitlint/types": "^21.2.3", + "conventional-changelog-conventionalcommits": "^10.0.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" + } + }, + "node_modules/@commitlint/config-conventional/node_modules/conventional-changelog-conventionalcommits": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-10.4.1.tgz", + "integrity": "sha512-jS1LRFSbRoAr6JbhhRy1JYfNNUj5LM3+v49hRE03CgccsiYqgdVj/RTmrw4J1rylrqijHwdOlJWiJa61JHOgHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@conventional-changelog/template": "^1.4.0" + }, + "engines": { + "node": ">=22" } }, "node_modules/@commitlint/config-validator": { - "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-18.6.1.tgz", - "integrity": "sha512-05uiToBVfPhepcQWE1ZQBR/Io3+tb3gEotZjnI4tTzzPk16NffN6YABgwFQCLmzZefbDcmwWqJWc2XT47q7Znw==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.2.3.tgz", + "integrity": "sha512-JQ13v0GafSM4fBYX8UowSy++07Was20auq0NcTdnzSEq6/05Cq5GBUMIfKkqjzyBwC9sJFmPpKWgLLhH7cygkw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@commitlint/types": "^18.6.1", + "@commitlint/types": "^21.2.3", "ajv": "^8.11.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/ensure": { - "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-18.6.1.tgz", - "integrity": "sha512-BPm6+SspyxQ7ZTsZwXc7TRQL5kh5YWt3euKmEIBZnocMFkJevqs3fbLRb8+8I/cfbVcAo4mxRlpTPfz8zX7SnQ==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.2.3.tgz", + "integrity": "sha512-gEjNKgRocbODI5/sMx2Qc9XOf2/zFTNV2aEK1QZ4gKDKxDC/VR7us3QtIX1C5d5xxHxx0I/tEbYbdSP1Am2LwA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@commitlint/types": "^18.6.1", - "lodash.camelcase": "^4.3.0", - "lodash.kebabcase": "^4.1.1", - "lodash.snakecase": "^4.1.1", - "lodash.startcase": "^4.4.0", - "lodash.upperfirst": "^4.3.1" + "@commitlint/types": "^21.2.3", + "es-toolkit": "^1.46.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/execute-rule": { - "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-18.6.1.tgz", - "integrity": "sha512-7s37a+iWyJiGUeMFF6qBlyZciUkF8odSAnHijbD36YDctLhGKoYltdvuJ/AFfRm6cBLRtRk9cCVPdsEFtt/2rg==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-21.0.1.tgz", + "integrity": "sha512-RifH+FmImozKBE6mozhF4K3r2RRKP7SMi/Q/zLCmExtp5e05lhHOUYqGBlFBAGNHaZxU/WYw1XuugYK9jQzqnA==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/format": { - "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-18.6.1.tgz", - "integrity": "sha512-K8mNcfU/JEFCharj2xVjxGSF+My+FbUHoqR+4GqPGrHNqXOGNio47ziiR4HQUPKtiNs05o8/WyLBoIpMVOP7wg==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.2.3.tgz", + "integrity": "sha512-KKwCjXYqA8r295uhLnulDXL9yekdXmj2yp2Q0XsBPhxQ2iPuvRE8F43j7g7yexo69wTdmUQmFz15jpderluzUQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@commitlint/types": "^18.6.1", - "chalk": "^4.1.0" + "@commitlint/types": "^21.2.3", + "picocolors": "^1.1.1" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/is-ignored": { - "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-18.6.1.tgz", - "integrity": "sha512-MOfJjkEJj/wOaPBw5jFjTtfnx72RGwqYIROABudOtJKW7isVjFe9j0t8xhceA02QebtYf4P/zea4HIwnXg8rvA==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.2.3.tgz", + "integrity": "sha512-Ng4nRj/LBAOvc+GQu7bsFW4/fL89LvZ14OOC/ERLbUpnG6QBwWRzY/1oXGwPs3C8YHWjJObin4kLYo2YeEn70w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@commitlint/types": "^18.6.1", - "semver": "7.6.0" + "@commitlint/types": "^21.2.3", + "semver": "^7.6.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/lint": { - "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-18.6.1.tgz", - "integrity": "sha512-8WwIFo3jAuU+h1PkYe5SfnIOzp+TtBHpFr4S8oJWhu44IWKuVx6GOPux3+9H1iHOan/rGBaiacicZkMZuluhfQ==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.2.3.tgz", + "integrity": "sha512-lUZcSuVLLEDkhge9ERk8Rp+FXQtrUhCK+h328BFnzKXrTuXGdqiYMEpTlNWdZaOrjyMhMJaz354ZaWEGJxcLeg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@commitlint/is-ignored": "^18.6.1", - "@commitlint/parse": "^18.6.1", - "@commitlint/rules": "^18.6.1", - "@commitlint/types": "^18.6.1" + "@commitlint/is-ignored": "^21.2.3", + "@commitlint/parse": "^21.2.3", + "@commitlint/rules": "^21.2.3", + "@commitlint/types": "^21.2.3" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/load": { - "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-18.6.1.tgz", - "integrity": "sha512-p26x8734tSXUHoAw0ERIiHyW4RaI4Bj99D8YgUlVV9SedLf8hlWAfyIFhHRIhfPngLlCe0QYOdRKYFt8gy56TA==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.2.3.tgz", + "integrity": "sha512-cDrL8lOo23Lk7knGzQdjbFcq7z2JgvjwV6PhbESVvEc+Fe21MeLjPKBJi8nKjg7j+jh79CVbVSUbZc104thXRw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@commitlint/config-validator": "^18.6.1", - "@commitlint/execute-rule": "^18.6.1", - "@commitlint/resolve-extends": "^18.6.1", - "@commitlint/types": "^18.6.1", - "chalk": "^4.1.0", - "cosmiconfig": "^8.3.6", - "cosmiconfig-typescript-loader": "^5.0.0", - "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2", - "lodash.uniq": "^4.5.0", - "resolve-from": "^5.0.0" + "@commitlint/config-validator": "^21.2.3", + "@commitlint/execute-rule": "^21.0.1", + "@commitlint/resolve-extends": "^21.2.3", + "@commitlint/types": "^21.2.3", + "cosmiconfig": "^9.0.1", + "cosmiconfig-typescript-loader": "^6.1.0", + "es-toolkit": "^1.46.0", + "is-plain-obj": "^4.1.0", + "picocolors": "^1.1.1" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/message": { - "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-18.6.1.tgz", - "integrity": "sha512-VKC10UTMLcpVjMIaHHsY1KwhuTQtdIKPkIdVEwWV+YuzKkzhlI3aNy6oo1eAN6b/D2LTtZkJe2enHmX0corYRw==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-21.2.0.tgz", + "integrity": "sha512-YxGoiXD/HXNXLJPrQwE5poXa+XH0CBEm+mdvbHQP0g6MV/dmJyUFCzPNzZbxL93GvZ70TmtTK0Z0/IBpAqHv8g==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/parse": { - "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-18.6.1.tgz", - "integrity": "sha512-eS/3GREtvVJqGZrwAGRwR9Gdno3YcZ6Xvuaa+vUF8j++wsmxrA2En3n0ccfVO2qVOLJC41ni7jSZhQiJpMPGOQ==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.2.3.tgz", + "integrity": "sha512-Jt61QpF1xA88damCELe8I0FlDZPSpXNc8IhN7z5kZ0EcDYPjdZ+ZHPvsSXl69r2MIlLnNKeHhOn2cmVpziFE9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@commitlint/types": "^18.6.1", - "conventional-changelog-angular": "^7.0.0", - "conventional-commits-parser": "^5.0.0" + "@commitlint/types": "^21.2.3", + "conventional-changelog-angular": "^9.0.0", + "conventional-commits-parser": "^7.0.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/read": { - "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-18.6.1.tgz", - "integrity": "sha512-ia6ODaQFzXrVul07ffSgbZGFajpe8xhnDeLIprLeyfz3ivQU1dIoHp7yz0QIorZ6yuf4nlzg4ZUkluDrGN/J/w==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.2.3.tgz", + "integrity": "sha512-szAepuLA1vTTr7fuudEg7jYyVHudf+FH+vptU91bhxE1WnE8TKrfTAi44HVllz8XpI8Pvp4GjR/iP5Nshgh+og==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@commitlint/top-level": "^18.6.1", - "@commitlint/types": "^18.6.1", - "git-raw-commits": "^2.0.11", - "minimist": "^1.2.6" + "@commitlint/top-level": "^21.2.0", + "@commitlint/types": "^21.2.3", + "@conventional-changelog/git-client": "^3.0.0", + "tinyexec": "^1.0.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/resolve-extends": { - "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-18.6.1.tgz", - "integrity": "sha512-ifRAQtHwK+Gj3Bxj/5chhc4L2LIc3s30lpsyW67yyjsETR6ctHAHRu1FSpt0KqahK5xESqoJ92v6XxoDRtjwEQ==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.2.3.tgz", + "integrity": "sha512-bqJdmAtXgAQel+l02uIsF7rHgKgx1AGZdGBjam7uK4XW54ZAQtB6XAIUXezYprQiCcRMpBh4fHs5JodXK4mVZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@commitlint/config-validator": "^18.6.1", - "@commitlint/types": "^18.6.1", - "import-fresh": "^3.0.0", - "lodash.mergewith": "^4.6.2", - "resolve-from": "^5.0.0", - "resolve-global": "^1.0.0" + "@commitlint/config-validator": "^21.2.3", + "@commitlint/types": "^21.2.3", + "es-toolkit": "^1.46.0", + "global-directory": "^5.0.0", + "resolve-from": "^5.0.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/rules": { - "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-18.6.1.tgz", - "integrity": "sha512-kguM6HxZDtz60v/zQYOe0voAtTdGybWXefA1iidjWYmyUUspO1zBPQEmJZ05/plIAqCVyNUTAiRPWIBKLCrGew==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.2.3.tgz", + "integrity": "sha512-5+YnI/LuTx3JDl9GjqfP2z9x96M8KKDrd+4jOel/T0L1BB3oAmBlSN8xppf63QsudiVEgR8C0gRoAMrPVrms6g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@commitlint/ensure": "^18.6.1", - "@commitlint/message": "^18.6.1", - "@commitlint/to-lines": "^18.6.1", - "@commitlint/types": "^18.6.1", - "execa": "^5.0.0" + "@commitlint/ensure": "^21.2.3", + "@commitlint/message": "^21.2.0", + "@commitlint/to-lines": "^21.0.1", + "@commitlint/types": "^21.2.3" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/to-lines": { - "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-18.6.1.tgz", - "integrity": "sha512-Gl+orGBxYSNphx1+83GYeNy5N0dQsHBQ9PJMriaLQDB51UQHCVLBT/HBdOx5VaYksivSf5Os55TLePbRLlW50Q==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-21.0.1.tgz", + "integrity": "sha512-bd1BFII7p1EQZre9Kaj+kKaMFP3cFCdt21K7DItVux9XP5WjLgJ0/Uy1pJJh9aPwVJ6SKg62PxqlZaHI8hQAXw==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/top-level": { - "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-18.6.1.tgz", - "integrity": "sha512-HyiHQZUTf0+r0goTCDs/bbVv/LiiQ7AVtz6KIar+8ZrseB9+YJAIo8HQ2IC2QT1y3N1lbW6OqVEsTHjbT6hGSw==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-21.2.0.tgz", + "integrity": "sha512-Y5gmQ+KxzqCrBFJfLvFEPvvwD3LDiNZoTT2yeFBm96M8qhmqSzQc5DvX3rheAaAMjyIvMXOCLS/mWfdpONsjyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "find-up": "^5.0.0" + "escalade": "^3.2.0" }, "engines": { - "node": ">=v18" + "node": ">=22.12.0" } }, "node_modules/@commitlint/types": { - "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-18.6.1.tgz", - "integrity": "sha512-gwRLBLra/Dozj2OywopeuHj2ac26gjGkz2cZ+86cTJOdtWfiRRr4+e77ZDAGc6MDWxaWheI+mAV5TLWWRwqrFg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "chalk": "^4.1.0" - }, - "engines": { - "node": ">=v18" - } - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.2.3.tgz", + "integrity": "sha512-sGEA473TlCvhCGNoTBY9OXZVWdscIXdGt0dm8EpB+2MRfmt8LNh/X56P/T3nCtEB+eH5qGEa3odphSAoUxSfSQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" + "conventional-commits-parser": "^7.0.0", + "picocolors": "^1.1.1" }, "engines": { - "node": ">=12" + "node": ">=22.12.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "node_modules/@conventional-changelog/git-client": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-3.2.0.tgz", + "integrity": "sha512-sD/E0vzpkoDz/dqM1Hhv0ihC4KBw6RXjefeO8SG1XrX+wGkMxe1pKeedkKDPCtr2JjuhbuS9aJNgOvnT8hbeBw==", "dev": true, "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.3.0" + "@simple-libs/child-process-utils": "^2.0.0", + "@simple-libs/stream-utils": "^2.0.0", + "verkit": "^0.5.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=22" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "conventional-commits-filter": "^6.0.1", + "conventional-commits-parser": "^7.1.3" + }, + "peerDependenciesMeta": { + "conventional-commits-filter": { + "optional": true + }, + "conventional-commits-parser": { + "optional": true + } } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", - "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", + "node_modules/@conventional-changelog/template": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/template/-/template-1.4.0.tgz", + "integrity": "sha512-aalGyl7dbB5PArRebDIX43ZvBlXrYm9uWzGJ26t+4SzJVPsOuvfILGGbw5X4yX7i50YEmJ8zvbiWnqH/AAnZqg==", "dev": true, "license": "MIT", "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=22" } }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=18" } }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "peer": true + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "peer": true, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=18" } }, - "node_modules/@fastify/merge-json-schemas": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", - "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" ], + "dev": true, "license": "MIT", - "dependencies": { - "dequal": "^2.0.3" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@gerrit0/mini-shiki": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.23.0.tgz", - "integrity": "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@shikijs/engine-oniguruma": "^3.23.0", - "@shikijs/langs": "^3.23.0", - "@shikijs/themes": "^3.23.0", - "@shikijs/types": "^3.23.0", - "@shikijs/vscode-textmate": "^10.0.2" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", - "deprecated": "Use @eslint/config-array instead", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=10.10.0" + "node": ">=18" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "peer": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=18" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], "dev": true, - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=18" } }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=18" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", - "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.0.0" + "node": ">=18" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">= 8" + "node": ">=18" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">= 8" + "node": ">=18" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">= 8" + "node": ">=18" } }, - "node_modules/@octokit/auth-token": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", - "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "peer": true, + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">= 20" + "node": ">=18" } }, - "node_modules/@octokit/core": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", - "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/auth-token": "^6.0.0", - "@octokit/graphql": "^9.0.3", - "@octokit/request": "^10.0.6", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "before-after-hook": "^4.0.0", - "universal-user-agent": "^7.0.0" - }, + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">= 20" + "node": ">=18" } }, - "node_modules/@octokit/endpoint": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", - "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.2" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 20" + "node": ">=18" } }, - "node_modules/@octokit/graphql": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", - "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/request": "^10.0.6", - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.0" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 20" + "node": ">=18" } }, - "node_modules/@octokit/openapi-types": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", - "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "peer": true + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@octokit/plugin-paginate-rest": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", - "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@octokit/types": "^16.0.0" + "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": ">= 20" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" }, "peerDependencies": { - "@octokit/core": ">=6" + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@octokit/plugin-retry": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-8.1.0.tgz", - "integrity": "sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw==", + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "bottleneck": "^2.15.3" - }, + "license": "Apache-2.0", "engines": { - "node": ">= 20" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, - "peerDependencies": { - "@octokit/core": ">=7" + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@octokit/plugin-throttling": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.3.tgz", - "integrity": "sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg==", + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/types": "^16.0.0", - "bottleneck": "^2.15.3" - }, "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "@octokit/core": "^7.0.0" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@octokit/request": { - "version": "10.0.8", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", - "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, - "license": "MIT", - "peer": true, + "license": "Apache-2.0", "dependencies": { - "@octokit/endpoint": "^11.0.3", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "fast-content-type-parse": "^3.0.0", - "json-with-bigint": "^3.5.3", - "universal-user-agent": "^7.0.2" + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" }, "engines": { - "node": ">= 20" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@octokit/request-error": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", - "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, - "license": "MIT", - "peer": true, + "license": "Apache-2.0", "dependencies": { - "@octokit/types": "^16.0.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": ">= 20" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@octokit/types": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", - "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, - "license": "MIT", - "peer": true, + "license": "Apache-2.0", "dependencies": { - "@octokit/openapi-types": "^27.0.0" + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", - "optional": true, "engines": { - "node": ">=14" - } - }, - "node_modules/@pnpm/config.env-replace": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", - "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, - "license": "MIT", - "peer": true, + "license": "Apache-2.0", "engines": { - "node": ">=12.22.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@pnpm/network.ca-file": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", - "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", "dev": true, - "license": "MIT", - "peer": true, + "license": "Apache-2.0", "dependencies": { - "graceful-fs": "4.2.10" + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" }, "engines": { - "node": ">=12.22.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", - "dev": true, - "license": "ISC", - "peer": true - }, - "node_modules/@pnpm/npm-conf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.2.tgz", - "integrity": "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==", + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.2.tgz", + "integrity": "sha512-d/gx9sRbUCVgGRGzma46wLcwiY8noTqO2Nmjg/vbTARxHRE5bdh5PIL8s97Lt1X7ScRWRW52eVYxwIoPwIdVUA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", - "peer": true, "dependencies": { - "@pnpm/config.env-replace": "^1.1.0", - "@pnpm/network.ca-file": "^1.0.1", - "config-chain": "^1.1.11" - }, - "engines": { - "node": ">=12" + "dequal": "^2.0.3" } }, - "node_modules/@sec-ant/readable-stream": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", - "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "node_modules/@gerrit0/mini-shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.23.0.tgz", + "integrity": "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==", "dev": true, "license": "MIT", - "peer": true + "dependencies": { + "@shikijs/engine-oniguruma": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", + "@shikijs/types": "^3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } }, - "node_modules/@semantic-release/changelog": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@semantic-release/changelog/-/changelog-6.0.3.tgz", - "integrity": "sha512-dZuR5qByyfe3Y03TpmCvAxCyTnp7r5XwtHRf/8vD9EAn4ZWbavUX8adMtXYzE86EVh0gyLA7lm5yW4IV30XUag==", + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, - "license": "MIT", - "peer": true, + "license": "Apache-2.0", "dependencies": { - "@semantic-release/error": "^3.0.0", - "aggregate-error": "^3.0.0", - "fs-extra": "^11.0.0", - "lodash": "^4.17.4" + "@humanfs/types": "^0.15.0" }, "engines": { - "node": ">=14.17" - }, - "peerDependencies": { - "semantic-release": ">=18.0.0" + "node": ">=18.18.0" } }, - "node_modules/@semantic-release/commit-analyzer": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/@semantic-release/commit-analyzer/-/commit-analyzer-13.0.1.tgz", - "integrity": "sha512-wdnBPHKkr9HhNhXOhZD5a2LNl91+hs8CC2vsAVYxtZH3y0dV3wKn+uZSN61rdJQZ8EGxzWB3inWocBHV9+u/CQ==", + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, - "license": "MIT", - "peer": true, + "license": "Apache-2.0", "dependencies": { - "conventional-changelog-angular": "^8.0.0", - "conventional-changelog-writer": "^8.0.0", - "conventional-commits-filter": "^5.0.0", - "conventional-commits-parser": "^6.0.0", - "debug": "^4.0.0", - "import-from-esm": "^2.0.0", - "lodash-es": "^4.17.21", - "micromatch": "^4.0.2" + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" }, "engines": { - "node": ">=20.8.1" - }, - "peerDependencies": { - "semantic-release": ">=20.1.0" + "node": ">=18.18.0" } }, - "node_modules/@semantic-release/commit-analyzer/node_modules/conventional-changelog-angular": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", - "integrity": "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==", + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "compare-func": "^2.0.0" - }, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=18.18.0" } }, - "node_modules/@semantic-release/commit-analyzer/node_modules/conventional-commits-parser": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", - "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@simple-libs/stream-utils": "^1.2.0", - "meow": "^13.0.0" - }, - "bin": { - "conventional-commits-parser": "dist/cli/index.js" - }, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@semantic-release/commit-analyzer/node_modules/meow": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", - "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "MIT", - "peer": true, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=18.18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@semantic-release/error": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-3.0.0.tgz", - "integrity": "sha512-5hiM4Un+tpl4cKw3lV4UgzJj+SmfNIDCLLw0TepzQxz9ZGV5ixnqkzIVF+3tp0ZHgcMKE+VNGHJjEeyFG2dcSw==", + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=14.17" + "node": ">=8" } }, - "node_modules/@semantic-release/git": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@semantic-release/git/-/git-10.0.1.tgz", - "integrity": "sha512-eWrx5KguUcU2wUPaO6sfvZI0wPafUKAMNC18aXY4EnNcrZL86dEmpNVnC9uMpGZkmZJ9EfCVJBQx4pV4EMGT1w==", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@semantic-release/error": "^3.0.0", - "aggregate-error": "^3.0.0", - "debug": "^4.0.0", - "dir-glob": "^3.0.0", - "execa": "^5.0.0", - "lodash": "^4.17.4", - "micromatch": "^4.0.0", - "p-reduce": "^2.0.0" - }, "engines": { - "node": ">=14.17" - }, - "peerDependencies": { - "semantic-release": ">=18.0.0" + "node": ">=6.0.0" } }, - "node_modules/@semantic-release/github": { - "version": "12.0.6", - "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.6.tgz", - "integrity": "sha512-aYYFkwHW3c6YtHwQF0t0+lAjlU+87NFOZuH2CvWFD0Ylivc7MwhZMiHOJ0FMpIgPpCVib/VUAcOwvrW0KnxQtA==", + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@octokit/core": "^7.0.0", - "@octokit/plugin-paginate-rest": "^14.0.0", - "@octokit/plugin-retry": "^8.0.0", - "@octokit/plugin-throttling": "^11.0.0", - "@semantic-release/error": "^4.0.0", - "aggregate-error": "^5.0.0", - "debug": "^4.3.4", - "dir-glob": "^3.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "issue-parser": "^7.0.0", - "lodash-es": "^4.17.21", - "mime": "^4.0.0", - "p-filter": "^4.0.0", - "tinyglobby": "^0.2.14", - "undici": "^7.0.0", - "url-join": "^5.0.0" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" }, "engines": { - "node": "^22.14.0 || >= 24.10.0" + "node": ">= 18" }, "peerDependencies": { - "semantic-release": ">=24.1.0" + "keyv": "^5.6.0" } }, - "node_modules/@semantic-release/github/node_modules/@semantic-release/error": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", - "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=18" + "node": ">= 20" } }, - "node_modules/@semantic-release/github/node_modules/aggregate-error": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", - "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "node_modules/@octokit/core": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.8.tgz", + "integrity": "sha512-L7y8eYc+AwxGr2PWI4WFt1VG4TiJ66c26BD16mXpYIlXxG0SMigM1+m4aTSlYyBr5BlQsGAlz8uDCoZN4SEMcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "clean-stack": "^5.2.0", - "indent-string": "^5.0.0" + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.5", + "@octokit/request": "^10.0.16", + "@octokit/request-error": "^7.1.2", + "@octokit/types": "^18.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 20" } }, - "node_modules/@semantic-release/github/node_modules/clean-stack": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz", - "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==", + "node_modules/@octokit/endpoint": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.5.tgz", + "integrity": "sha512-iXa654H3yFafF/ieHkukfbgWo2rmXD2ceD0ZOtrPhw1bc3FDch1d9N/TNs0FQ1/cIbwb7kspUX8jzIs8nzb9DQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "escape-string-regexp": "5.0.0" + "@octokit/types": "^18.0.0", + "universal-user-agent": "^7.0.2" }, "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 20" } }, - "node_modules/@semantic-release/github/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "node_modules/@octokit/graphql": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.5.tgz", + "integrity": "sha512-bt/hm03LeU6Vy7FwTrkkC9p3XGT/lBwClglMqxBSe5/q0E5CdJTXeAqEI0vlw89/LF/G6tryTIH8HirZ3prMVg==", "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" + "dependencies": { + "@octokit/request": "^10.0.16", + "@octokit/types": "^18.0.0", + "universal-user-agent": "^7.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 20" } }, - "node_modules/@semantic-release/github/node_modules/indent-string": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "node_modules/@octokit/openapi-types": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-29.0.1.tgz", + "integrity": "sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg==", "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "MIT" }, - "node_modules/@semantic-release/npm": { - "version": "13.1.5", - "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-13.1.5.tgz", - "integrity": "sha512-Hq5UxzoatN3LHiq2rTsWS54nCdqJHlsssGERCo8WlvdfFA9LoN0vO+OuKVSjtNapIc/S8C2LBj206wKLHg62mg==", + "node_modules/@octokit/plugin-paginate-rest": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-15.0.0.tgz", + "integrity": "sha512-lw9A9YL5s4VPj+VEx8uMxaxQm2YTgrPDkrLEuZuu18R8TK3oPcXF4K6t52nx6xn1RcogZC9i188sAr/4XYJaQQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@actions/core": "^3.0.0", - "@semantic-release/error": "^4.0.0", - "aggregate-error": "^5.0.0", - "env-ci": "^11.2.0", - "execa": "^9.0.0", - "fs-extra": "^11.0.0", - "lodash-es": "^4.17.21", - "nerf-dart": "^1.0.0", - "normalize-url": "^9.0.0", - "npm": "^11.6.2", - "rc": "^1.2.8", - "read-pkg": "^10.0.0", - "registry-auth-token": "^5.0.0", - "semver": "^7.1.2", - "tempy": "^3.0.0" + "@octokit/types": "^17.0.0" }, "engines": { - "node": "^22.14.0 || >= 24.10.0" + "node": ">= 20" }, "peerDependencies": { - "semantic-release": ">=20.1.0" + "@octokit/core": ">=6" } }, - "node_modules/@semantic-release/npm/node_modules/@semantic-release/error": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", - "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-28.0.0.tgz", + "integrity": "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-17.0.0.tgz", + "integrity": "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==", "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" + "dependencies": { + "@octokit/openapi-types": "^28.0.0" } }, - "node_modules/@semantic-release/npm/node_modules/aggregate-error": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", - "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "node_modules/@octokit/plugin-retry": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-8.1.1.tgz", + "integrity": "sha512-VCVvZ/R1+u3WuiBWpNavZ0mY4aaJNAsENrpBP9aLSR2QyOpQgd7DhM5j4AW7z4MQpnJYgwBPf0XqPQoNBRdQwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "clean-stack": "^5.2.0", - "indent-string": "^5.0.0" + "@octokit/request-error": "^7.1.1", + "@octokit/types": "^17.0.0", + "bottleneck": "^2.15.3" }, "engines": { - "node": ">=18" + "node": ">= 20" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "@octokit/core": ">=7" } }, - "node_modules/@semantic-release/npm/node_modules/clean-stack": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz", - "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==", + "node_modules/@octokit/plugin-retry/node_modules/@octokit/openapi-types": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-28.0.0.tgz", + "integrity": "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-retry/node_modules/@octokit/types": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-17.0.0.tgz", + "integrity": "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "escape-string-regexp": "5.0.0" + "@octokit/openapi-types": "^28.0.0" + } + }, + "node_modules/@octokit/plugin-throttling": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.5.tgz", + "integrity": "sha512-LIdrkrUv+DWbKeg/49rGuFJ3SU0d3hUS+B4MhNZLepBoNUFXms8Ic9edJjrlx+zycqJHjrMRudVpVb/bAXM2Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^17.0.0", + "bottleneck": "^2.15.3" }, "engines": { - "node": ">=14.16" + "node": ">= 20" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "@octokit/core": "^7.0.0" } }, - "node_modules/@semantic-release/npm/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "node_modules/@octokit/plugin-throttling/node_modules/@octokit/openapi-types": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-28.0.0.tgz", + "integrity": "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-throttling/node_modules/@octokit/types": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-17.0.0.tgz", + "integrity": "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==", "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "@octokit/openapi-types": "^28.0.0" } }, - "node_modules/@semantic-release/npm/node_modules/execa": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", - "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "node_modules/@octokit/request": { + "version": "10.0.16", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.16.tgz", + "integrity": "sha512-A0zWGjHzISIb+9ccG8s0dq7LKO5zVpJLRICjgUb+sJxEWqn8RUHB1rD3AE51+PECvXHIxqZ1VVvs4fHTSD9nUQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "cross-spawn": "^7.0.6", - "figures": "^6.1.0", - "get-stream": "^9.0.0", - "human-signals": "^8.0.1", - "is-plain-obj": "^4.1.0", - "is-stream": "^4.0.1", - "npm-run-path": "^6.0.0", - "pretty-ms": "^9.2.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^4.0.0", - "yoctocolors": "^2.1.1" + "@octokit/endpoint": "^11.0.5", + "@octokit/request-error": "^7.1.2", + "@octokit/types": "^18.0.0", + "content-type": "^3.0.0", + "json-with-bigint": "^3.5.12", + "universal-user-agent": "^7.0.2" }, "engines": { - "node": "^18.19.0 || >=20.5.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" + "node": ">= 20" } }, - "node_modules/@semantic-release/npm/node_modules/get-stream": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "node_modules/@octokit/request-error": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.2.tgz", + "integrity": "sha512-XZRuT3xZ84D3gYErI1DZvhJ33dCWVV6uzBtWkaBB4TvA/L6eOeTZodxLFVB44bBEEo3vEx7y00UfX1tBLrtLRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" + "@octokit/types": "^18.0.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 20" } }, - "node_modules/@semantic-release/npm/node_modules/human-signals": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", - "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "node_modules/@octokit/types": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-18.0.0.tgz", + "integrity": "sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==", "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=18.18.0" + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^29.0.1" } }, - "node_modules/@semantic-release/npm/node_modules/indent-string": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=12" + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "4.2.10" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=12.22.0" } }, - "node_modules/@semantic-release/npm/node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@pnpm/npm-conf": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.3.tgz", + "integrity": "sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==", "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" + }, "engines": { "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/npm/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@semantic-release/changelog": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/changelog/-/changelog-7.0.0.tgz", + "integrity": "sha512-TNPyag5db24o7jWjre7UwKB4EcL8oJxbRhnDQ7hmZRAYqzreAc6PgdxQuU3pppp5xQinYtiumL0iG8SSKvnlzg==", "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "lodash-es": "^4.17.21" + }, "engines": { - "node": ">=18" + "node": "^22.22.2 || >=24.15" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "semantic-release": ">=20.1.0" } }, - "node_modules/@semantic-release/npm/node_modules/npm-run-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "node_modules/@semantic-release/commit-analyzer": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/@semantic-release/commit-analyzer/-/commit-analyzer-13.0.1.tgz", + "integrity": "sha512-wdnBPHKkr9HhNhXOhZD5a2LNl91+hs8CC2vsAVYxtZH3y0dV3wKn+uZSN61rdJQZ8EGxzWB3inWocBHV9+u/CQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" + "conventional-changelog-angular": "^8.0.0", + "conventional-changelog-writer": "^8.0.0", + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.0.0", + "debug": "^4.0.0", + "import-from-esm": "^2.0.0", + "lodash-es": "^4.17.21", + "micromatch": "^4.0.2" }, "engines": { - "node": ">=18" + "node": ">=20.8.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "semantic-release": ">=20.1.0" } }, - "node_modules/@semantic-release/npm/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "node_modules/@semantic-release/commit-analyzer/node_modules/@simple-libs/stream-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", + "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://ko-fi.com/dangreen" } }, - "node_modules/@semantic-release/npm/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, + "node_modules/@semantic-release/commit-analyzer/node_modules/conventional-changelog-angular": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", + "integrity": "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==", + "dev": true, "license": "ISC", - "peer": true, + "dependencies": { + "compare-func": "^2.0.0" + }, "engines": { - "node": ">=14" + "node": ">=18" + } + }, + "node_modules/@semantic-release/commit-analyzer/node_modules/conventional-commits-filter": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", + "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/commit-analyzer/node_modules/conventional-commits-parser": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", + "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "meow": "^13.0.0" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" } }, - "node_modules/@semantic-release/npm/node_modules/strip-final-newline": { + "node_modules/@semantic-release/error": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" + } + }, + "node_modules/@semantic-release/exec": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@semantic-release/exec/-/exec-7.1.0.tgz", + "integrity": "sha512-4ycZ2atgEUutspPZ2hxO6z8JoQt4+y/kkHvfZ1cZxgl9WKJId1xPj+UadwInj+gMn2Gsv+fLnbrZ4s+6tK2TFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^3.0.0", + "debug": "^4.0.0", + "execa": "^9.0.0", + "lodash-es": "^4.17.21", + "parse-json": "^8.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=20.8.1" + }, + "peerDependencies": { + "semantic-release": ">=24.1.0" } }, - "node_modules/@semantic-release/npm/node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "node_modules/@semantic-release/exec/node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, "engines": { - "node": ">=18" + "node": ">=8" + } + }, + "node_modules/@semantic-release/exec/node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@semantic-release/exec/node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@semantic-release/git": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@semantic-release/git/-/git-11.0.1.tgz", + "integrity": "sha512-Zr8BUYCTZMc8V6wDKN2dpR7nJgewd9I6THL3ydLTnp3OEdTo1/4RBLNYaeRucYMsjMv+BXoCNfXA0NADj1kwhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "debug": "^4.0.0", + "dir-glob": "^3.0.0", + "execa": "^10.0.0", + "lodash-es": "^4.17.21", + "micromatch": "^4.0.0", + "p-reduce": "^3.0.0" + }, + "engines": { + "node": "^22.22.2 || >=24.15" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@semantic-release/git/node_modules/execa": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-10.1.1.tgz", + "integrity": "sha512-yJAC9ulsE6fU0K6wzJBIH+wI+KAtMeu6Ck8rs23OVzvEhqXjGADdI6K+YNIDAjcQQ6AwXTD96cKExJo6xFJXyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "figures": "^6.1.0", + "get-stream": "^9.0.1", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.3.1", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "which-command": "^0.1.0", + "yoctocolors": "^2.2.0" + }, + "engines": { + "node": ">=22" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@semantic-release/github": { + "version": "12.0.10", + "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.10.tgz", + "integrity": "sha512-QskygvpX8hFx9UEbqJ+C46OIhaJjsysNQ8PYJPuRDc/ek5+dh8WYGD1ofUahCcGLAOmnnwZTcmCJCEXM8XxhRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.0", + "@octokit/plugin-paginate-rest": "^15.0.0", + "@octokit/plugin-retry": "^8.0.0", + "@octokit/plugin-throttling": "^11.0.0", + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "debug": "^4.3.4", + "dir-glob": "^3.0.1", + "http-proxy-agent": "^9.0.0", + "https-proxy-agent": "^9.0.0", + "issue-parser": "^7.0.0", + "lodash-es": "^4.17.21", + "mime": "^4.0.0", + "p-filter": "^4.0.0", + "tinyglobby": "^0.2.14", + "undici": "^7.0.0", + "url-join": "^5.0.0" + }, + "engines": { + "node": "^22.14.0 || >= 24.10.0" + }, + "peerDependencies": { + "semantic-release": ">=24.1.0" + } + }, + "node_modules/@semantic-release/npm": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-13.2.0.tgz", + "integrity": "sha512-Uz4JGafLoEGiheLKEs1orockCWP6liY9D2YnvKHYbqo9hCHAw9fuAHcU53q29SXVCtkvDhs/QVUg7Vvuyfp4Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/core": "^3.0.0", + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "env-ci": "^11.2.0", + "execa": "^9.0.0", + "fs-extra": "^11.0.0", + "lodash-es": "^4.17.21", + "nerf-dart": "^1.0.0", + "normalize-url": "^9.0.0", + "npm": "^11.6.2", + "rc": "^1.2.8", + "read-pkg": "^10.0.0", + "registry-auth-token": "^5.0.0", + "semver": "^7.1.2", + "tempy": "^3.0.0" + }, + "engines": { + "node": "^22.14.0 || >= 24.10.0" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" } }, "node_modules/@semantic-release/release-notes-generator": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.0.tgz", - "integrity": "sha512-CcyDRk7xq+ON/20YNR+1I/jP7BYKICr1uKd1HHpROSnnTdGqOTburi4jcRiTYz0cpfhxSloQO3cGhnoot7IEkA==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.1.tgz", + "integrity": "sha512-Pbd2e2XRMUD0OxehHpgd5/YghsE76cddkRHSoDvKLK+OCy4Ewxn49rWR631MEUU01lgwF/uyVXvbnVuu6+Z6VA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "conventional-changelog-angular": "^8.0.0", "conventional-changelog-writer": "^8.0.0", "conventional-commits-filter": "^5.0.0", "conventional-commits-parser": "^6.0.0", "debug": "^4.0.0", - "get-stream": "^7.0.0", "import-from-esm": "^2.0.0", - "into-stream": "^7.0.0", "lodash-es": "^4.17.21", "read-package-up": "^11.0.0" }, @@ -1604,13 +1766,25 @@ "semantic-release": ">=20.1.0" } }, + "node_modules/@semantic-release/release-notes-generator/node_modules/@simple-libs/stream-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", + "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, "node_modules/@semantic-release/release-notes-generator/node_modules/conventional-changelog-angular": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", "integrity": "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "compare-func": "^2.0.0" }, @@ -1618,13 +1792,22 @@ "node": ">=18" } }, + "node_modules/@semantic-release/release-notes-generator/node_modules/conventional-commits-filter": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", + "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@semantic-release/release-notes-generator/node_modules/conventional-commits-parser": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@simple-libs/stream-utils": "^1.2.0", "meow": "^13.0.0" @@ -1636,27 +1819,12 @@ "node": ">=18" } }, - "node_modules/@semantic-release/release-notes-generator/node_modules/get-stream": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-7.0.1.tgz", - "integrity": "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@semantic-release/release-notes-generator/node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "lru-cache": "^10.0.1" }, @@ -1664,19 +1832,12 @@ "node": "^16.14.0 || >=18.0.0" } }, - "node_modules/@semantic-release/release-notes-generator/node_modules/meow": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", - "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "node_modules/@semantic-release/release-notes-generator/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "ISC" }, "node_modules/@semantic-release/release-notes-generator/node_modules/normalize-package-data": { "version": "6.0.2", @@ -1684,7 +1845,6 @@ "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", @@ -1694,32 +1854,12 @@ "node": "^16.14.0 || >=18.0.0" } }, - "node_modules/@semantic-release/release-notes-generator/node_modules/parse-json": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", - "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.26.2", - "index-to-position": "^1.1.0", - "type-fest": "^4.39.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@semantic-release/release-notes-generator/node_modules/read-package-up": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", @@ -1738,7 +1878,6 @@ "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/normalize-package-data": "^2.4.3", "normalize-package-data": "^6.0.0", @@ -1753,27 +1892,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/release-notes-generator/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "peer": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@semantic-release/release-notes-generator/node_modules/unicorn-magic": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1830,15 +1954,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@simple-libs/child-process-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz", + "integrity": "sha512-dvNoRKLijXnD0XoJAz94pbNuB5GQgDr55UhpSPhffDkTT0Cmcqh9jSCOtwfT2d4H6MI9E7c4SgtMuJXZ6F3c6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^2.0.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, "node_modules/@simple-libs/stream-utils": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", - "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-2.0.0.tgz", + "integrity": "sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "url": "https://ko-fi.com/dangreen" @@ -1850,7 +1989,6 @@ "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -1864,7 +2002,6 @@ "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1877,27 +2014,29 @@ "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "type-detect": "4.0.8" } }, "node_modules/@sinonjs/fake-timers": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.2.tgz", - "integrity": "sha512-4Bb+oqXZTSTZ1q27Izly9lv8B9dlV61CROxPiVtywwzv5SnytJqhvYe6FclHYuXml4cd1VHPo1zd5PmTeJozvA==", + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1" } }, "node_modules/@sinonjs/samsam": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", - "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "lodash.get": "^4.4.2", "type-detect": "^4.1.0" } }, @@ -1906,79 +2045,39 @@ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, - "node_modules/@sinonjs/text-encoding": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", - "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", - "dev": true - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/eslint": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.0.tgz", - "integrity": "sha512-gi6WQJ7cHRgZxtkQEoyHMppPjq9Kxo5Tjn2prSKDSmZrCz8TZ3jSRCeTJm+WoM+oB0WG37bRqLzaaU3q7JypGg==", - "dev": true, + "node_modules/@types/busboy": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.5.tgz", + "integrity": "sha512-IwqrfVFxWa23c4WeE7uZswssCqGfvnl5Q7b1pdshbT3HaQDa3UIbJx5BAxtcj9nZYX9tVDArM9TUv9SpvORbpQ==", "license": "MIT", "peer": true, "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" + "@types/node": "*" } }, - "node_modules/@types/eslint__js": { - "version": "8.42.3", - "resolved": "https://registry.npmjs.org/@types/eslint__js/-/eslint__js-8.42.3.tgz", - "integrity": "sha512-alfG737uhmPdnvkrLdZLcEKJ/B8s9Y4hrZ+YAdzUeoArBlSUERA2E87ROfOaS4jd/C45fzOoZzidLc1IPwLqOw==", + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint": "*" - } + "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", "dev": true, "license": "MIT", "dependencies": { @@ -1997,33 +2096,15 @@ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/mocha": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.7.tgz", - "integrity": "sha512-GN8yJ1mNTcFcah/wKEFIJckJx9iJLoMSzWcfRRuxz/Jk+U6KQNnml+etbtxFK8lPjzOw3zp4Ha/kjSst9fsHYw==", - "dev": true, "license": "MIT" }, "node_modules/@types/node": { - "version": "22.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.0.tgz", - "integrity": "sha512-DkFrJOe+rfdHTqqMg0bSNlGlQ85hSoh2TPzZyhHsXnMtligRWpxUySiyw8FY14ITt24HVCiQPWxS3KO/QlGmWg==", - "dev": true, + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", "license": "MIT", - "peer": true, "dependencies": { - "undici-types": "~6.19.2" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/normalize-package-data": { @@ -2031,13 +2112,12 @@ "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/sinon": { - "version": "17.0.3", - "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.3.tgz", - "integrity": "sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw==", + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.4.tgz", + "integrity": "sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==", "dev": true, "license": "MIT", "dependencies": { @@ -2045,9 +2125,9 @@ } }, "node_modules/@types/sinonjs__fake-timers": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", - "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz", + "integrity": "sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==", "dev": true, "license": "MIT" }, @@ -2059,267 +2139,239 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", - "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", + "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/type-utils": "7.18.0", - "@typescript-eslint/utils": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/type-utils": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^7.0.0", - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "@typescript-eslint/parser": "^8.71.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", - "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/typescript-estree": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", - "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0" + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", + "debug": "^4.4.3" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", - "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "7.18.0", - "@typescript-eslint/utils": "7.18.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.3.0" + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } } }, - "node_modules/@typescript-eslint/types": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", - "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", - "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "node_modules/@typescript-eslint/types": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", "dev": true, "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, "engines": { - "node": ">=10" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.1" + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", - "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/typescript-estree": "7.18.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", - "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.18.0", - "eslint-visitor-keys": "^3.4.3" + "@typescript-eslint/types": "8.71.2", + "eslint-visitor-keys": "^5.0.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true, - "license": "ISC", - "peer": true - }, "node_modules/acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", "dev": true, "license": "MIT", "bin": { @@ -2335,54 +2387,41 @@ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.3", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz", - "integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">= 14" + "node": ">= 20" } }, "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -2414,23 +2453,12 @@ } } }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-escapes": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", - "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "environment": "^1.0.0" }, @@ -2442,26 +2470,26 @@ } }, "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -2472,28 +2500,6 @@ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -2503,97 +2509,81 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/argue-cli": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/argue-cli/-/argue-cli-3.2.0.tgz", + "integrity": "sha512-VipTB0gXgGIFO2Rg9yEVN5wLt2AurJcZqDbgmYSwPwsykhLrQhs240/bfceev4w68lI8JshoOJIk9uW7tFzROw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, "node_modules/argv-formatter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/argv-formatter/-/argv-formatter-1.0.0.tgz", "integrity": "sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/array-ify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/arrify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", - "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } + "license": "MIT" }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", "engines": { "node": ">=8.0.0" } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/before-after-hook": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", "dev": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "node_modules/binden": { + "resolved": "packages/binden", + "link": true }, "node_modules/bottleneck": { "version": "2.19.5", "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/braces": { @@ -2609,17 +2599,21 @@ "node": ">=8" } }, - "node_modules/browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true, - "license": "ISC" + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } }, "node_modules/c8": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz", - "integrity": "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz", + "integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==", "dev": true, "license": "ISC", "dependencies": { @@ -2632,14 +2626,14 @@ "istanbul-reports": "^3.1.6", "test-exclude": "^8.0.0", "v8-to-istanbul": "^9.0.0", - "yargs": "^17.7.2", + "yargs": "^18.0.0", "yargs-parser": "^21.1.1" }, "bin": { "c8": "bin/c8.js" }, "engines": { - "node": "20 || >=22" + "node": "^20.19.0 || ^22.12.0 || >=23" }, "peerDependencies": { "monocart-coverage-reports": "^2" @@ -2650,72 +2644,38 @@ } } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/camelcase-keys": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", - "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "camelcase": "^5.3.1", - "map-obj": "^4.0.0", - "quick-lru": "^4.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" } }, - "node_modules/camelcase-keys/node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } }, "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" @@ -2727,58 +2687,24 @@ "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "node_modules/clean-stack": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz", + "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==", "dev": true, "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "escape-string-regexp": "5.0.0" }, "engines": { - "node": ">= 8.10.0" + "node": ">=14.16" }, "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/cli-highlight": { @@ -2787,7 +2713,6 @@ "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", @@ -2804,52 +2729,49 @@ "npm": ">=5.0.0" } }, - "node_modules/cli-highlight/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/cli-highlight/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/cli-highlight/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/cli-highlight/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/cli-highlight/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/cli-highlight/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" } }, "node_modules/cli-highlight/node_modules/wrap-ansi": { @@ -2858,7 +2780,6 @@ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -2872,12 +2793,11 @@ } }, "node_modules/cli-highlight/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", @@ -2897,7 +2817,6 @@ "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=10" } @@ -2908,7 +2827,6 @@ "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "string-width": "^4.2.0" }, @@ -2919,104 +2837,60 @@ "@colors/colors": "1.5.0" } }, - "node_modules/cli-table3/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/cli-table3/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-table3/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "dev": true, "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=12" + "node": ">=20" } }, "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, - "node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "node_modules/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-regex": "^6.2.2" }, "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/color-convert": { @@ -3039,82 +2913,73 @@ "dev": true, "license": "MIT" }, - "node_modules/commitlint": { - "version": "18.6.1", - "resolved": "https://registry.npmjs.org/commitlint/-/commitlint-18.6.1.tgz", - "integrity": "sha512-I10mj1OmBCrPUHItRqeCEj0uxCdWxL15sCfS1Poq8av2FcX/KvRoiH8jTNG0cVDA2ns7IftugTAM+nLvOavLsw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@commitlint/cli": "^18.6.1", - "@commitlint/types": "^18.6.1" - }, - "bin": { - "commitlint": "cli.js" - }, - "engines": { - "node": ">=v18" - } - }, "node_modules/compare-func": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, + "node_modules/config-chain/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/content-type": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-3.1.1.tgz", + "integrity": "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/conventional-changelog-angular": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", - "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-9.4.0.tgz", + "integrity": "sha512-HdxRxuS8bBXVIuo4V82gvSwAXT0vYQUizrjs/izmPg5JdDstr8v8I5hduGL3iQbG+o310dUDxC4+LetuS5hu9w==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { - "compare-func": "^2.0.0" + "@conventional-changelog/template": "^1.4.0" }, "engines": { - "node": ">=16" + "node": ">=22" } }, "node_modules/conventional-changelog-conventionalcommits": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz", - "integrity": "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.1.tgz", + "integrity": "sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "compare-func": "^2.0.0" }, "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/conventional-changelog-writer": { @@ -3123,7 +2988,6 @@ "integrity": "sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@simple-libs/stream-utils": "^1.2.0", "conventional-commits-filter": "^5.0.0", @@ -3138,49 +3002,44 @@ "node": ">=18" } }, - "node_modules/conventional-changelog-writer/node_modules/meow": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", - "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "node_modules/conventional-changelog-writer/node_modules/@simple-libs/stream-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", + "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://ko-fi.com/dangreen" } }, - "node_modules/conventional-commits-filter": { + "node_modules/conventional-changelog-writer/node_modules/conventional-commits-filter": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" } }, "node_modules/conventional-commits-parser": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", - "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-7.1.3.tgz", + "integrity": "sha512-Q9oWm1o9uy/oEsZQIraoFMZ4RuPD+79KgwWj6mJVP356tazolCB0piRLZ5Kz9te9p027uSEcUW+Gu44Eh+X79g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "is-text-path": "^2.0.0", - "JSONStream": "^1.3.5", - "meow": "^12.0.1", - "split2": "^4.0.0" + "@simple-libs/stream-utils": "^2.0.0", + "argue-cli": "^3.1.0" }, "bin": { - "conventional-commits-parser": "cli.mjs" + "conventional-commits-parser": "dist/cli/index.js" }, "engines": { - "node": ">=16" + "node": ">=22" } }, "node_modules/convert-hrtime": { @@ -3189,7 +3048,6 @@ "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3209,21 +3067,19 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { + "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" + "parse-json": "^5.2.0" }, "engines": { "node": ">=14" @@ -3241,30 +3097,41 @@ } }, "node_modules/cosmiconfig-typescript-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-5.0.0.tgz", - "integrity": "sha512-+8cK7jRAReYkMwMiG+bxhcNKiHJDM6bR9FD/nGBXOWdMLuYawjF5cGrtLilJ+LGd3ZjCXnJjR5DkfWPoIVlqJA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", + "integrity": "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "jiti": "^1.19.1" + "jiti": "2.6.1" }, "engines": { - "node": ">=v16" + "node": ">=v18" }, "peerDependencies": { "@types/node": "*", - "cosmiconfig": ">=8.2", - "typescript": ">=4" + "cosmiconfig": ">=9", + "typescript": ">=5" } }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "node_modules/cosmiconfig/node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -3287,7 +3154,6 @@ "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "type-fest": "^1.0.1" }, @@ -3304,7 +3170,6 @@ "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", "dev": true, "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { "node": ">=10" }, @@ -3312,17 +3177,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/dargs": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/dargs/-/dargs-7.0.0.tgz", - "integrity": "sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -3334,13 +3188,13 @@ } }, "node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -3351,66 +3205,12 @@ } } }, - "node_modules/decamelize": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", - "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decamelize-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", - "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "decamelize": "^1.1.0", - "map-obj": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decamelize-keys/node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/decamelize-keys/node_modules/map-obj": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", - "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4.0.0" } @@ -3420,8 +3220,7 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dequal": { "version": "2.0.3", @@ -3434,9 +3233,9 @@ } }, "node_modules/diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -3456,27 +3255,12 @@ "node": ">=8" } }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "is-obj": "^2.0.0" }, @@ -3490,33 +3274,23 @@ "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "readable-stream": "^2.0.2" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/emoji-regex": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", - "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/emojilib": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/entities": { "version": "4.5.0", @@ -3537,7 +3311,6 @@ "integrity": "sha512-D5kWfzkmaOQDioPmiviWAVtKmpPT4/iJmMVQxWxMPJTFyTkdc5JQUfc5iXEeWxcOdsYTKSAiA/Age4NUOqKsRA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "execa": "^8.0.0", "java-properties": "^1.0.2" @@ -3552,7 +3325,6 @@ "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", @@ -3577,7 +3349,6 @@ "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16" }, @@ -3591,7 +3362,6 @@ "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=16.17.0" } @@ -3602,7 +3372,6 @@ "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, @@ -3610,27 +3379,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/env-ci/node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/env-ci/node_modules/npm-run-path": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "path-key": "^4.0.0" }, @@ -3641,30 +3395,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/env-ci/node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/env-ci/node_modules/path-key": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3672,27 +3408,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/env-ci/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "peer": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/env-ci/node_modules/strip-final-newline": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3706,7 +3427,6 @@ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -3717,7 +3437,6 @@ "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -3726,185 +3445,190 @@ } }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "is-arrayish": "^0.2.1" } }, - "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6" - } + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, + "hasInstallScript": true, "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, "engines": { - "node": ">=10" + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", "dev": true, "license": "MIT", - "peer": true, + "workspaces": [ + "packages/*" + ], "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-config-binden-js": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-binden-js/-/eslint-config-binden-js-3.0.0.tgz", - "integrity": "sha512-mGtXYZi67X1QhstQbrd2wGRdaSQbD+iZokAeNMWq8Q6Wb035Rs2Wb+SBC/R1Jan9aAitVwppVyn10UZ0KJwYJQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint/js": "^9.0.0" - }, - "engines": { - "node": ">=20.12.1", - "npm": ">=10.5.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "type": "Coinbase Commerce", - "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + "url": "https://eslint.org/donate" }, "peerDependencies": { - "@types/eslint__js": "^8.42.3", - "eslint": "^8.57.0" - } - }, - "node_modules/eslint-config-binden-js/node_modules/@eslint/js": { - "version": "9.9.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.1.tgz", - "integrity": "sha512-xIDQRsfg5hNBqHz04H1R3scSVwmI+KUbqjsQKHKQ1DAUSaUjYPReZZmS/5PNiKu1fUvzDd6H7DEDKACSEhu+TQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/eslint-config-binden-ts": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/eslint-config-binden-ts/-/eslint-config-binden-ts-4.0.2.tgz", - "integrity": "sha512-3uub/HfmrYCU9j8gaL393NYqNFa5JCDniTMqAYiIb5eusbh1L37PdYD1U/+eDgHMsJCvwTspsr22oNVKSpITWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-config-binden-js": "^3.0.0", - "typescript-eslint": "^7.13.0" + "jiti": "*" }, - "engines": { - "node": ">=20.12.1", - "npm": ">=10.5.0" - }, - "funding": { - "type": "Coinbase Commerce", - "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3916,40 +3640,50 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { - "acorn": "^8.9.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -3963,7 +3697,6 @@ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -3977,7 +3710,6 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -3988,54 +3720,37 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" }, "engines": { - "node": ">=10" + "node": "^18.19.0 || >=20.5.0" }, "funding": { "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/fast-content-type-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "peer": true - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4043,48 +3758,17 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-json-stringify": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", - "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.1.0.tgz", + "integrity": "sha512-wAhAQ+es0aH+x5OofdCHlZZFdqwtiM6lpEosaRIwfg271WYeEQ/tPcXdu5vpzlqZpbJ4WNqaW4QR5UjfG4Dc2w==", "dev": true, "funding": [ { @@ -4107,9 +3791,9 @@ } }, "node_modules/fast-json-stringify/node_modules/fast-uri": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.2.tgz", - "integrity": "sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.2.tgz", + "integrity": "sha512-EwVpUMzLVNOmcWW0zi5G3JJwkopB6XXjnVvNBGfqrMMsVew2hkQWl5e/pe/CfYUeS6jt4cIO2MnamEW7v/7wPw==", "dev": true, "funding": [ { @@ -4128,32 +3812,41 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/fast-redact": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", - "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", - "engines": { - "node": ">=6" - } + "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", - "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, - "license": "MIT" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" }, - "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, "node_modules/fetch-blob": { @@ -4186,7 +3879,6 @@ "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "is-unicode-supported": "^2.0.0" }, @@ -4197,32 +3889,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/figures/node_modules/is-unicode-supported": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.0.0.tgz", - "integrity": "sha512-FRdAyx5lusK1iHG0TWpVtk9+1i+GjrzRffhDg4ovQ7mcidMQ6mj+MhKPmvh7Xwyv5gIS06ns49CA7Sqg7lC22Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" + "flat-cache": "^6.1.23" } }, "node_modules/fill-range": { @@ -4261,7 +3935,6 @@ "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -4275,7 +3948,6 @@ "integrity": "sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "semver-regex": "^4.0.5", "super-regex": "^1.0.0" @@ -4287,48 +3959,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "license": "BSD-3-Clause", - "bin": { - "flat": "cli.js" - } - }, "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" } }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -4338,19 +3995,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -4364,25 +4008,12 @@ "node": ">=12.20.0" } }, - "node_modules/from2": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", - "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "inherits": "^2.0.1", - "readable-stream": "^2.0.0" - } - }, "node_modules/fs-extra": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", - "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -4392,14 +4023,6 @@ "node": ">=14.14" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC", - "peer": true - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4415,24 +4038,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/function-timeout": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-1.0.2.tgz", "integrity": "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -4451,12 +4062,11 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz", - "integrity": "sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -4465,14 +4075,17 @@ } }, "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -4484,7 +4097,6 @@ "integrity": "sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "argv-formatter": "~1.0.0", "spawn-error-forwarder": "~1.0.0", @@ -4494,4856 +4106,3311 @@ "traverse": "0.6.8" } }, - "node_modules/git-log-parser/node_modules/split2": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-1.0.0.tgz", - "integrity": "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "through2": "~2.0.0" - } - }, - "node_modules/git-log-parser/node_modules/through2": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", - "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "readable-stream": "~2.3.6", - "xtend": "~4.0.1" - } - }, - "node_modules/git-raw-commits": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-2.0.11.tgz", - "integrity": "sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==", + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "MIT", - "peer": true, + "license": "BlueOak-1.0.0", "dependencies": { - "dargs": "^7.0.0", - "lodash": "^4.17.15", - "meow": "^8.0.0", - "split2": "^3.0.0", - "through2": "^4.0.0" - }, - "bin": { - "git-raw-commits": "cli.js" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": ">=10" - } - }, - "node_modules/git-raw-commits/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" + "node": "18 || 20 || >=22" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/git-raw-commits/node_modules/hosted-git-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", - "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { - "lru-cache": "^6.0.0" + "is-glob": "^4.0.3" }, "engines": { - "node": ">=10" + "node": ">=10.13.0" } }, - "node_modules/git-raw-commits/node_modules/locate-path": { + "node_modules/global-directory": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz", + "integrity": "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "p-locate": "^4.1.0" + "ini": "6.0.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/git-raw-commits/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "yallist": "^4.0.0" + "node": ">=20" }, - "engines": { - "node": ">=10" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/git-raw-commits/node_modules/meow": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", - "integrity": "sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==", + "node_modules/globals": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@types/minimist": "^1.2.0", - "camelcase-keys": "^6.2.2", - "decamelize-keys": "^1.1.0", - "hard-rejection": "^2.1.0", - "minimist-options": "4.1.0", - "normalize-package-data": "^3.0.0", - "read-pkg-up": "^7.0.1", - "redent": "^3.0.0", - "trim-newlines": "^3.0.0", - "type-fest": "^0.18.0", - "yargs-parser": "^20.2.3" - }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/git-raw-commits/node_modules/normalize-package-data": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", - "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "hosted-git-info": "^4.0.1", - "is-core-module": "^2.5.0", - "semver": "^7.3.4", - "validate-npm-package-license": "^3.0.1" - }, - "engines": { - "node": ">=10" - } + "license": "ISC" }, - "node_modules/git-raw-commits/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "node_modules/handlebars": { + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "p-try": "^2.0.0" + "minimist": "^1.2.8", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" }, "engines": { - "node": ">=6" + "node": ">=0.4.7" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "optionalDependencies": { + "uglify-js": "^3.1.4" } }, - "node_modules/git-raw-commits/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "p-limit": "^2.2.0" - }, "engines": { "node": ">=8" } }, - "node_modules/git-raw-commits/node_modules/read-pkg": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", - "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@types/normalize-package-data": "^2.4.0", - "normalize-package-data": "^2.5.0", - "parse-json": "^5.0.0", - "type-fest": "^0.6.0" + "hookified": "^1.15.0" }, "engines": { - "node": ">=8" + "node": ">=20" } }, - "node_modules/git-raw-commits/node_modules/read-pkg-up": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", - "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hook-std": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hook-std/-/hook-std-4.0.0.tgz", + "integrity": "sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "find-up": "^4.1.0", - "read-pkg": "^5.2.0", - "type-fest": "^0.8.1" - }, "engines": { - "node": ">=8" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/git-raw-commits/node_modules/read-pkg-up/node_modules/type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", "dev": true, - "license": "(MIT OR CC0-1.0)", - "peer": true, - "engines": { - "node": ">=8" - } + "license": "MIT" }, - "node_modules/git-raw-commits/node_modules/read-pkg/node_modules/hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", "dev": true, "license": "ISC", - "peer": true - }, - "node_modules/git-raw-commits/node_modules/read-pkg/node_modules/normalize-package-data": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, "dependencies": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/git-raw-commits/node_modules/read-pkg/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver" - } + "license": "MIT" }, - "node_modules/git-raw-commits/node_modules/read-pkg/node_modules/type-fest": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", - "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", "dev": true, - "license": "(MIT OR CC0-1.0)", - "peer": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, "engines": { - "node": ">=8" + "node": ">= 20" } }, - "node_modules/git-raw-commits/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" }, "engines": { - "node": ">= 6" + "node": ">= 20" } }, - "node_modules/git-raw-commits/node_modules/split2": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", - "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "readable-stream": "^3.0.0" + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" } }, - "node_modules/git-raw-commits/node_modules/type-fest": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", - "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", "dev": true, - "license": "(MIT OR CC0-1.0)", - "peer": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/typicode" } }, - "node_modules/git-raw-commits/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, - "license": "ISC", - "peer": true, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">= 4" } }, - "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, + "license": "MIT", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": ">=6" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "is-glob": "^4.0.3" - }, + "license": "MIT", "engines": { - "node": ">=10.13.0" + "node": ">=4" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "node_modules/import-from-esm": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-2.0.0.tgz", + "integrity": "sha512-YVt14UZCgsX1vZQ3gKjkWVdBdHQ6eu3MPU1TBgL1H5orXe2+jWD006WCPPtOuwlQm10NuzOW5WawiF1Q9veW8g==", "dev": true, + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.1" + "debug": "^4.3.4", + "import-meta-resolve": "^4.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, + "node": ">=18.20" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/global-dirs": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", - "integrity": "sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "ini": "^1.3.4" - }, "engines": { - "node": ">=4" + "node": ">=0.8.19" } }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "type-fest": "^0.20.2" - }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", "dev": true, - "license": "ISC", - "peer": true + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true, - "license": "MIT" + "license": "ISC" }, - "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, + "license": "ISC", "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/hard-rejection": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", - "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=6" + "node": ">=0.10.0" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "function-bind": "^1.1.2" + "is-extglob": "^2.1.1" }, "engines": { - "node": ">= 0.4" + "node": ">=0.10.0" } }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, "license": "MIT", - "bin": { - "he": "bin/he" + "engines": { + "node": ">=0.12.0" } }, - "node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", "dev": true, - "license": "BSD-3-Clause", - "peer": true, + "license": "MIT", "engines": { - "node": "*" + "node": ">=8" } }, - "node_modules/hook-std": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/hook-std/-/hook-std-4.0.0.tgz", - "integrity": "sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ==", + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=20" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/hosted-git-info": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", - "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "lru-cache": "^11.1.0" - }, + "license": "MIT", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", "dev": true, - "license": "BlueOak-1.0.0", - "peer": true, + "license": "MIT", "engines": { - "node": "20 || >=22" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, "license": "MIT" }, - "node_modules/http-proxy-agent": { + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/issue-parser": { "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.2.tgz", + "integrity": "sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" + "lodash.capitalize": "^4.2.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.uniqby": "^4.7.0" }, "engines": { - "node": ">= 14" + "node": "^18.17 || >=20.6.1" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, + "license": "BSD-3-Clause", "engines": { - "node": ">= 14" + "node": ">=8" } }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, - "license": "Apache-2.0", - "peer": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": ">=10.17.0" + "node": ">=10" } }, - "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, - "bin": { - "husky": "bin.js" + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" + "node": ">=8" } }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", + "node_modules/java-properties": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", + "integrity": "sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 4" + "node": ">= 0.6.0" } }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "bin": { + "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/import-fresh/node_modules/resolve-from": { + "node_modules/js-tokens": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } + "license": "MIT" }, - "node_modules/import-from-esm": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-2.0.0.tgz", - "integrity": "sha512-YVt14UZCgsX1vZQ3gKjkWVdBdHQ6eu3MPU1TBgL1H5orXe2+jWD006WCPPtOuwlQm10NuzOW5WawiF1Q9veW8g==", + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", - "peer": true, "dependencies": { - "debug": "^4.3.4", - "import-meta-resolve": "^4.0.0" + "argparse": "^2.0.1" }, - "engines": { - "node": ">=18.20" + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/import-meta-resolve": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", - "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } + "license": "MIT" }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.8.19" - } + "license": "MIT" }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "node_modules/json-schema-ref-resolver": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.1.tgz", + "integrity": "sha512-w+HwgljXpVwZ3IidoUR1qPZxSOQ+NRQWuVe6a5pe8XbHsNZE4UAwVPUvzZagn7yBKbJtt6UJnRFYbNTRPZI45Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" + "dependencies": { + "dequal": "^2.0.3" } }, - "node_modules/index-to-position": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", - "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "MIT" }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "ISC", - "peer": true, + "license": "MIT" + }, + "node_modules/json-with-bigint": { + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.12.tgz", + "integrity": "sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", "dev": true, - "license": "ISC", - "peer": true + "license": "MIT" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, - "license": "ISC", - "peer": true + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } }, - "node_modules/into-stream": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-7.0.0.tgz", - "integrity": "sha512-2dYz766i9HprMBasCMvHMuazJ7u4WzhJwo5kb3iPSiW/iRYV6uPari3zHoqZlnuaR7V1bEiNMxikhp37rdBXbw==", + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "from2": "^2.3.0", - "p-is-promise": "^3.0.0" + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.8.0" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" + "uc.micro": "^2.0.0" } }, - "node_modules/is-core-module": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", - "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "node_modules/lint-staged": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.6.0.tgz", + "integrity": "sha512-nOhBfYkZEwkFtNzD5pK9s9LkcWoxJ+whLzEnhqi9nl7zTB5n7tg8+9iYgMGx2Wm3VlLu8cbNj0RACzY+nWi5iQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "hasown": "^2.0.2" + "picomatch": "^4.0.7", + "string-argv": "^0.3.2", + "tinyexec": "^1.3.1" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" }, "engines": { - "node": ">= 0.4" + "node": ">=22.22.1" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/lint-staged" + }, + "optionalDependencies": { + "yaml": "^2.9.1" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", "dev": true, "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=4" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/load-json-file/node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", "dev": true, "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" }, "engines": { - "node": ">=0.10.0" + "node": ">=4" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, "engines": { - "node": ">=0.12.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } + "license": "MIT" }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "node_modules/lodash.capitalize": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz", + "integrity": "sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==", "dev": true, - "license": "MIT", - "peer": true, + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniqby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", + "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=8" + "node": "20 || >=22" } }, - "node_modules/is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/make-asynchronous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.1.0.tgz", + "integrity": "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==", "dev": true, "license": "MIT", + "dependencies": { + "p-event": "^6.0.0", + "type-fest": "^4.6.0", + "web-worker": "^1.5.0" + }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "semver": "^7.5.3" + }, "engines": { - "node": ">=8" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-text-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz", - "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==", + "node_modules/markdown-it": { + "version": "14.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", + "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", - "peer": true, "dependencies": { - "text-extensions": "^2.0.0" + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" }, "engines": { - "node": ">=8" + "node": ">= 18" } }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "node_modules/marked-terminal": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-7.3.0.tgz", + "integrity": "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==", "dev": true, "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "ansi-regex": "^6.1.0", + "chalk": "^5.4.1", + "cli-highlight": "^2.1.11", + "cli-table3": "^0.6.5", + "node-emoji": "^2.2.0", + "supports-hyperlinks": "^3.1.0" + }, "engines": { - "node": ">=10" + "node": ">=16.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "marked": ">=1 <16" } }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", "dev": true, "license": "MIT", - "peer": true + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/isexe": { + "node_modules/merge-stream": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true, - "license": "ISC" + "license": "MIT" }, - "node_modules/issue-parser": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.1.tgz", - "integrity": "sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==", + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "lodash.capitalize": "^4.2.1", - "lodash.escaperegexp": "^4.1.2", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.uniqby": "^4.7.0" + "braces": "^3.0.3", + "picomatch": "^2.3.1" }, "engines": { - "node": "^18.17 || >=20.6.1" + "node": ">=8.6" } }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "node_modules/mime": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.1.tgz", + "integrity": "sha512-YAApRsTpM2+gnUFHhRNGfkX8NK0V5xj1nyQLufGlwEI+eX4xRsTcel2IXDuMEGSGjBvzykNW4VuoEACgwYe7BQ==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" }, "engines": { - "node": ">=10" + "node": ">=16" } }, - "node_modules/istanbul-reports": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", - "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/cliui": "^8.0.2" + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/java-properties": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", - "integrity": "sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==", + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jiti": { - "version": "1.21.6", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", - "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/json-parse-better-errors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", - "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, - "node_modules/json-schema-ref-resolver": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", - "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], "license": "MIT", "dependencies": { - "dequal": "^2.0.3" + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" } }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/json-with-bigint": { - "version": "3.5.8", - "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", - "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true, - "engines": [ - "node >= 0.2.0" - ], - "license": "MIT", - "peer": true + "license": "MIT" }, - "node_modules/JSONStream": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", - "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "node_modules/nerf-dart": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/nerf-dart/-/nerf-dart-1.0.0.tgz", + "integrity": "sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==", "dev": true, - "license": "(MIT OR Apache-2.0)", - "peer": true, - "dependencies": { - "jsonparse": "^1.2.0", - "through": ">=2.2.7 <3" - }, - "bin": { - "JSONStream": "bin.js" - }, - "engines": { - "node": "*" - } - }, - "node_modules/just-extend": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", - "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", - "dev": true + "license": "MIT" }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/nise": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.5.tgz", + "integrity": "sha512-SnRDPDBjxZZoU2n0+gzzLtSvo1OZo7j6jnbXsoh3AFxEGhaFU7ZF0TmefuKERq79wxR2U+MPn7ArW+Tl+clC3A==", "dev": true, - "license": "MIT", - "peer": true, + "license": "BSD-3-Clause", "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^15.1.1", + "just-extend": "^6.2.0", + "path-to-regexp": "^8.3.0" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/nise/node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, - "license": "MIT", - "peer": true, + "license": "BSD-3-Clause", "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" + "@sinonjs/commons": "^3.0.1" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/linkify-it": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", - "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", "dev": true, "funding": [ { "type": "github", - "url": "https://github.com/sponsors/puzrin" + "url": "https://github.com/sponsors/jimmywarting" }, { "type": "github", - "url": "https://github.com/sponsors/markdown-it" + "url": "https://paypal.me/jimmywarting" } ], "license": "MIT", - "dependencies": { - "uc.micro": "^2.0.0" + "engines": { + "node": ">=10.5.0" } }, - "node_modules/lint-staged": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.3.0.tgz", - "integrity": "sha512-woZS3vNe3UKqBaLPvbLOtKRY4tLANpWQhom12MGWqC8Mh1lCOO+WgSwmX2amjJAqTY9BkXYW87fCUH5H9Ph6xw==", + "node_modules/node-emoji": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", + "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", "dev": true, "license": "MIT", "dependencies": { - "picomatch": "^4.0.5", - "string-argv": "^0.3.2", - "tinyexec": "^1.2.4" - }, - "bin": { - "lint-staged": "bin/lint-staged.js" + "@sindresorhus/is": "^4.6.0", + "char-regex": "^1.0.2", + "emojilib": "^2.4.0", + "skin-tone": "^2.0.0" }, "engines": { - "node": ">=22.22.1" - }, - "funding": { - "url": "https://opencollective.com/lint-staged" - }, - "optionalDependencies": { - "yaml": "^2.9.0" + "node": ">=18" } }, - "node_modules/lint-staged/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", "dev": true, "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, "engines": { - "node": ">=12" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" } }, - "node_modules/load-json-file": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", - "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "node_modules/normalize-package-data": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", "dev": true, - "license": "MIT", - "peer": true, + "license": "BSD-2-Clause", "dependencies": { - "graceful-fs": "^4.1.2", - "parse-json": "^4.0.0", - "pify": "^3.0.0", - "strip-bom": "^3.0.0" + "hosted-git-info": "^9.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" }, "engines": { - "node": ">=4" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/load-json-file/node_modules/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "node_modules/normalize-url": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.1.tgz", + "integrity": "sha512-ARftfC5HdUNu9jJeL8pHj8debUIHA2b91FizCoMzY4lG6dDX13jdvTK0TBe24IBDRf2HvJSzzwEPvmbkQWHRSg==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "error-ex": "^1.3.1", - "json-parse-better-errors": "^1.0.1" - }, "engines": { - "node": ">=4" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash-es": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", - "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.capitalize": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz", - "integrity": "sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.escaperegexp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", - "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "dev": true - }, - "node_modules/lodash.isfunction": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", - "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.kebabcase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", - "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.snakecase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.startcase": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", - "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.uniq": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.uniqby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", - "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.upperfirst": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", - "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/lunr": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", - "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, - "node_modules/map-obj": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", - "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/markdown-it": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", - "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/markdown-it" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.1", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" - }, - "bin": { - "markdown-it": "bin/markdown-it.mjs" - } - }, - "node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/marked-terminal": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-7.3.0.tgz", - "integrity": "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-escapes": "^7.0.0", - "ansi-regex": "^6.1.0", - "chalk": "^5.4.1", - "cli-highlight": "^2.1.11", - "cli-table3": "^0.6.5", - "node-emoji": "^2.2.0", - "supports-hyperlinks": "^3.1.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "marked": ">=1 <16" - } - }, - "node_modules/marked-terminal/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/marked-terminal/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "dev": true, - "license": "MIT" - }, - "node_modules/meow": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", - "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=16.10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", - "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa" - ], - "license": "MIT", - "peer": true, - "bin": { - "mime": "bin/cli.js" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minimist-options": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", - "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "arrify": "^1.0.1", - "is-plain-obj": "^1.1.0", - "kind-of": "^6.0.3" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/minimist-options/node_modules/is-plain-obj": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", - "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mocha": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.0.1.tgz", - "integrity": "sha512-+3GkODfsDG71KSCQhc4IekSW+ItCK/kiez1Z28ksWvYhKXV/syxMlerR/sC7whDp7IyreZ4YxceMLdTs5hQE8A==", - "dev": true, - "dependencies": { - "ansi-colors": "^4.1.3", - "browser-stdout": "^1.3.1", - "chokidar": "^3.5.3", - "debug": "^4.3.5", - "diff": "^5.2.0", - "escape-string-regexp": "^4.0.0", - "find-up": "^5.0.0", - "glob": "^10.4.5", - "he": "^1.2.0", - "js-yaml": "^4.1.0", - "log-symbols": "^4.1.0", - "minimatch": "^5.1.6", - "ms": "^2.1.3", - "serialize-javascript": "^6.0.2", - "strip-json-comments": "^3.1.1", - "supports-color": "^8.1.1", - "workerpool": "^6.5.1", - "yargs": "^16.2.0", - "yargs-parser": "^20.2.9", - "yargs-unparser": "^2.0.0" - }, - "bin": { - "_mocha": "bin/_mocha", - "mocha": "bin/mocha.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/mocha/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/mocha/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/mocha/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/mocha/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mocha/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mocha/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/mocha/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/mocha/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/mocha/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mocha/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/nerf-dart": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/nerf-dart/-/nerf-dart-1.0.0.tgz", - "integrity": "sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/nise": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz", - "integrity": "sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==", - "dev": true, - "dependencies": { - "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^13.0.1", - "@sinonjs/text-encoding": "^0.7.3", - "just-extend": "^6.2.0", - "path-to-regexp": "^8.1.0" - } - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-emoji": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", - "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@sindresorhus/is": "^4.6.0", - "char-regex": "^1.0.2", - "emojilib": "^2.4.0", - "skin-tone": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, - "node_modules/normalize-package-data": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", - "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "hosted-git-info": "^9.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-url": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.0.tgz", - "integrity": "sha512-z9nC87iaZXXySbWWtTHfCFJyFvKaUAW6lODhikG7ILSbVgmwuFjUqkgnheHvAUcGedO29e2QGBRXMUD64aurqQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm": { - "version": "11.13.0", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.13.0.tgz", - "integrity": "sha512-cRmhaghDWA1lFgl3Ug4/VxDJdPBK/U+tNtnrl9kXunFqhWw1x4xL5txkNn7qzPuVfvXOmXyjHpMwsuk2uisbkg==", - "bundleDependencies": [ - "@isaacs/string-locale-compare", - "@npmcli/arborist", - "@npmcli/config", - "@npmcli/fs", - "@npmcli/map-workspaces", - "@npmcli/metavuln-calculator", - "@npmcli/package-json", - "@npmcli/promise-spawn", - "@npmcli/redact", - "@npmcli/run-script", - "@sigstore/tuf", - "abbrev", - "archy", - "cacache", - "chalk", - "ci-info", - "fastest-levenshtein", - "fs-minipass", - "glob", - "graceful-fs", - "hosted-git-info", - "ini", - "init-package-json", - "is-cidr", - "json-parse-even-better-errors", - "libnpmaccess", - "libnpmdiff", - "libnpmexec", - "libnpmfund", - "libnpmorg", - "libnpmpack", - "libnpmpublish", - "libnpmsearch", - "libnpmteam", - "libnpmversion", - "make-fetch-happen", - "minimatch", - "minipass", - "minipass-pipeline", - "ms", - "node-gyp", - "nopt", - "npm-audit-report", - "npm-install-checks", - "npm-package-arg", - "npm-pick-manifest", - "npm-profile", - "npm-registry-fetch", - "npm-user-validate", - "p-map", - "pacote", - "parse-conflict-json", - "proc-log", - "qrcode-terminal", - "read", - "semver", - "spdx-expression-parse", - "ssri", - "supports-color", - "tar", - "text-table", - "tiny-relative-date", - "treeverse", - "validate-npm-package-name", - "which" - ], - "dev": true, - "license": "Artistic-2.0", - "peer": true, - "workspaces": [ - "docs", - "smoke-tests", - "mock-globals", - "mock-registry", - "workspaces/*" - ], - "dependencies": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.4.3", - "@npmcli/config": "^10.8.1", - "@npmcli/fs": "^5.0.0", - "@npmcli/map-workspaces": "^5.0.3", - "@npmcli/metavuln-calculator": "^9.0.3", - "@npmcli/package-json": "^7.0.5", - "@npmcli/promise-spawn": "^9.0.1", - "@npmcli/redact": "^4.0.0", - "@npmcli/run-script": "^10.0.4", - "@sigstore/tuf": "^4.0.2", - "abbrev": "^4.0.0", - "archy": "~1.0.0", - "cacache": "^20.0.4", - "chalk": "^5.6.2", - "ci-info": "^4.4.0", - "fastest-levenshtein": "^1.0.16", - "fs-minipass": "^3.0.3", - "glob": "^13.0.6", - "graceful-fs": "^4.2.11", - "hosted-git-info": "^9.0.2", - "ini": "^6.0.0", - "init-package-json": "^8.2.5", - "is-cidr": "^6.0.4", - "json-parse-even-better-errors": "^5.0.0", - "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.1.6", - "libnpmexec": "^10.2.6", - "libnpmfund": "^7.0.20", - "libnpmorg": "^8.0.1", - "libnpmpack": "^9.1.6", - "libnpmpublish": "^11.1.3", - "libnpmsearch": "^9.0.1", - "libnpmteam": "^8.0.2", - "libnpmversion": "^8.0.3", - "make-fetch-happen": "^15.0.5", - "minimatch": "^10.2.5", - "minipass": "^7.1.3", - "minipass-pipeline": "^1.2.4", - "ms": "^2.1.2", - "node-gyp": "^12.3.0", - "nopt": "^9.0.0", - "npm-audit-report": "^7.0.0", - "npm-install-checks": "^8.0.0", - "npm-package-arg": "^13.0.2", - "npm-pick-manifest": "^11.0.3", - "npm-profile": "^12.0.1", - "npm-registry-fetch": "^19.1.1", - "npm-user-validate": "^4.0.0", - "p-map": "^7.0.4", - "pacote": "^21.5.0", - "parse-conflict-json": "^5.0.1", - "proc-log": "^6.1.0", - "qrcode-terminal": "^0.12.0", - "read": "^5.0.1", - "semver": "^7.7.4", - "spdx-expression-parse": "^4.0.0", - "ssri": "^13.0.1", - "supports-color": "^10.2.2", - "tar": "^7.5.13", - "text-table": "~0.2.0", - "tiny-relative-date": "^2.0.2", - "treeverse": "^3.0.0", - "validate-npm-package-name": "^7.0.2", - "which": "^6.0.1" - }, - "bin": { - "npm": "bin/npm-cli.js", - "npx": "bin/npx-cli.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/@gar/promise-retry": { - "version": "1.0.3", - "dev": true, - "inBundle": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/npm/node_modules/@isaacs/string-locale-compare": { - "version": "1.1.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "peer": true - }, - "node_modules/npm/node_modules/@npmcli/agent": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^11.2.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.4.3", - "dev": true, - "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "@gar/promise-retry": "^1.0.0", - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/fs": "^5.0.0", - "@npmcli/installed-package-contents": "^4.0.0", - "@npmcli/map-workspaces": "^5.0.0", - "@npmcli/metavuln-calculator": "^9.0.2", - "@npmcli/name-from-folder": "^4.0.0", - "@npmcli/node-gyp": "^5.0.0", - "@npmcli/package-json": "^7.0.0", - "@npmcli/query": "^5.0.0", - "@npmcli/redact": "^4.0.0", - "@npmcli/run-script": "^10.0.0", - "bin-links": "^6.0.0", - "cacache": "^20.0.1", - "common-ancestor-path": "^2.0.0", - "hosted-git-info": "^9.0.0", - "json-stringify-nice": "^1.1.4", - "lru-cache": "^11.2.1", - "minimatch": "^10.0.3", - "nopt": "^9.0.0", - "npm-install-checks": "^8.0.0", - "npm-package-arg": "^13.0.0", - "npm-pick-manifest": "^11.0.1", - "npm-registry-fetch": "^19.0.0", - "pacote": "^21.0.2", - "parse-conflict-json": "^5.0.1", - "proc-log": "^6.0.0", - "proggy": "^4.0.0", - "promise-all-reject-late": "^1.0.0", - "promise-call-limit": "^3.0.1", - "semver": "^7.3.7", - "ssri": "^13.0.0", - "treeverse": "^3.0.0", - "walk-up-path": "^4.0.0" - }, - "bin": { - "arborist": "bin/index.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.8.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "@npmcli/map-workspaces": "^5.0.0", - "@npmcli/package-json": "^7.0.0", - "ci-info": "^4.0.0", - "ini": "^6.0.0", - "nopt": "^9.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "walk-up-path": "^4.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/fs": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/git": { - "version": "7.0.2", - "dev": true, - "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "@gar/promise-retry": "^1.0.0", - "@npmcli/promise-spawn": "^9.0.0", - "ini": "^6.0.0", - "lru-cache": "^11.2.1", - "npm-pick-manifest": "^11.0.1", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "which": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/installed-package-contents": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "npm-bundled": "^5.0.0", - "npm-normalize-package-bin": "^5.0.0" - }, - "bin": { - "installed-package-contents": "bin/index.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/map-workspaces": { - "version": "5.0.3", - "dev": true, - "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "@npmcli/name-from-folder": "^4.0.0", - "@npmcli/package-json": "^7.0.0", - "glob": "^13.0.0", - "minimatch": "^10.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { - "version": "9.0.3", - "dev": true, - "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "cacache": "^20.0.0", - "json-parse-even-better-errors": "^5.0.0", - "pacote": "^21.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/name-from-folder": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "peer": true, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/node-gyp": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "peer": true, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/package-json": { - "version": "7.0.5", + "node_modules/npm": { + "version": "11.21.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.21.0.tgz", + "integrity": "sha512-Zov8KhamNneiLdELtj5YALtNmJW4L4fCLTzjfpzXG2w6MSHcf0UxgdlK5uloCuksWT+7mGUU7wi79cO6RqivPg==", + "bundleDependencies": [ + "@isaacs/string-locale-compare", + "@npmcli/arborist", + "@npmcli/config", + "@npmcli/fs", + "@npmcli/map-workspaces", + "@npmcli/metavuln-calculator", + "@npmcli/package-json", + "@npmcli/promise-spawn", + "@npmcli/redact", + "@npmcli/run-script", + "@sigstore/tuf", + "abbrev", + "archy", + "cacache", + "chalk", + "ci-info", + "fastest-levenshtein", + "fs-minipass", + "glob", + "graceful-fs", + "hosted-git-info", + "ini", + "init-package-json", + "is-cidr", + "json-parse-even-better-errors", + "libnpmaccess", + "libnpmdiff", + "libnpmexec", + "libnpmfund", + "libnpmorg", + "libnpmpack", + "libnpmpublish", + "libnpmsearch", + "libnpmteam", + "libnpmversion", + "make-fetch-happen", + "minimatch", + "minipass", + "minipass-pipeline", + "ms", + "node-gyp", + "nopt", + "npm-audit-report", + "npm-install-checks", + "npm-package-arg", + "npm-pick-manifest", + "npm-profile", + "npm-registry-fetch", + "npm-user-validate", + "p-map", + "pacote", + "parse-conflict-json", + "proc-log", + "qrcode-terminal", + "read", + "semver", + "spdx-expression-parse", + "ssri", + "supports-color", + "tar", + "text-table", + "tiny-relative-date", + "treeverse", + "validate-npm-package-name", + "which" + ], "dev": true, - "inBundle": true, - "license": "ISC", - "peer": true, + "license": "Artistic-2.0", + "workspaces": [ + "docs", + "smoke-tests", + "mock-globals", + "mock-registry", + "workspaces/*" + ], "dependencies": { - "@npmcli/git": "^7.0.0", - "glob": "^13.0.0", - "hosted-git-info": "^9.0.0", + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/arborist": "^9.9.2", + "@npmcli/config": "^10.14.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/map-workspaces": "^5.0.3", + "@npmcli/metavuln-calculator": "^9.0.3", + "@npmcli/package-json": "^7.0.5", + "@npmcli/promise-spawn": "^9.0.1", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.4", + "@sigstore/tuf": "^4.0.2", + "abbrev": "^4.0.0", + "archy": "~1.0.0", + "cacache": "^20.0.4", + "chalk": "^5.6.2", + "ci-info": "^4.4.0", + "fastest-levenshtein": "^1.0.16", + "fs-minipass": "^3.0.3", + "glob": "^13.0.6", + "graceful-fs": "^4.2.11", + "hosted-git-info": "^9.0.3", + "ini": "^6.0.0", + "init-package-json": "^8.2.5", + "is-cidr": "^6.0.4", "json-parse-even-better-errors": "^5.0.0", - "proc-log": "^6.0.0", - "semver": "^7.5.3", - "spdx-expression-parse": "^4.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/promise-spawn": { - "version": "9.0.1", - "dev": true, - "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "which": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/query": { - "version": "5.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/redact": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "peer": true, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/run-script": { - "version": "10.0.4", - "dev": true, - "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "@npmcli/node-gyp": "^5.0.0", - "@npmcli/package-json": "^7.0.0", - "@npmcli/promise-spawn": "^9.0.0", - "node-gyp": "^12.1.0", - "proc-log": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@sigstore/bundle": { - "version": "4.0.0", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@sigstore/protobuf-specs": "^0.5.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@sigstore/core": { - "version": "3.2.0", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@sigstore/protobuf-specs": { - "version": "0.5.1", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@sigstore/sign": { - "version": "4.1.1", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@gar/promise-retry": "^1.0.2", - "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.2.0", - "@sigstore/protobuf-specs": "^0.5.0", - "make-fetch-happen": "^15.0.4", - "proc-log": "^6.1.0" + "libnpmaccess": "^10.0.3", + "libnpmdiff": "^8.1.13", + "libnpmexec": "^10.3.3", + "libnpmfund": "^7.0.27", + "libnpmorg": "^8.0.1", + "libnpmpack": "^9.1.14", + "libnpmpublish": "^11.2.1", + "libnpmsearch": "^9.0.1", + "libnpmteam": "^8.0.2", + "libnpmversion": "^8.0.4", + "make-fetch-happen": "^15.0.6", + "minimatch": "^10.2.5", + "minipass": "^7.1.3", + "minipass-pipeline": "^1.2.4", + "ms": "^2.1.2", + "node-gyp": "^12.4.0", + "nopt": "^9.0.0", + "npm-audit-report": "^7.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.2", + "npm-pick-manifest": "^11.0.3", + "npm-profile": "^12.0.2", + "npm-registry-fetch": "^19.1.1", + "npm-user-validate": "^4.0.0", + "p-map": "^7.0.4", + "pacote": "^21.5.1", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.1.0", + "qrcode-terminal": "^0.12.0", + "read": "^5.0.1", + "semver": "^7.8.5", + "spdx-expression-parse": "^4.0.0", + "ssri": "^13.0.1", + "supports-color": "^10.2.2", + "tar": "^7.5.22", + "text-table": "~0.2.0", + "tiny-relative-date": "^2.0.2", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^7.0.2", + "which": "^6.0.1" }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@sigstore/tuf": { - "version": "4.0.2", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@sigstore/protobuf-specs": "^0.5.0", - "tuf-js": "^4.1.0" + "bin": { + "npm": "bin/npm-cli.js", + "npx": "bin/npx-cli.js" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/@sigstore/verify": { - "version": "3.1.0", + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "peer": true, + "license": "MIT", "dependencies": { - "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", - "@sigstore/protobuf-specs": "^0.5.0" + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/npm/node_modules/@tufjs/canonical-json": { - "version": "2.0.0", + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dev": true, - "inBundle": true, "license": "MIT", - "peer": true, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/npm/node_modules/@tufjs/models": { - "version": "4.1.0", + "node_modules/npm/node_modules/@gar/promise-retry": { + "version": "1.0.3", "dev": true, "inBundle": true, "license": "MIT", - "peer": true, - "dependencies": { - "@tufjs/canonical-json": "2.0.0", - "minimatch": "^10.1.1" - }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/abbrev": { - "version": "4.0.0", + "node_modules/npm/node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, + "dependencies": { + "minipass": "^7.0.4" + }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=18.0.0" } }, - "node_modules/npm/node_modules/agent-base": { - "version": "7.1.4", + "node_modules/npm/node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 14" - } + "license": "ISC" }, - "node_modules/npm/node_modules/aproba": { - "version": "2.1.0", + "node_modules/npm/node_modules/@npmcli/agent": { + "version": "4.0.2", "dev": true, "inBundle": true, "license": "ISC", - "peer": true - }, - "node_modules/npm/node_modules/archy": { - "version": "1.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "peer": true - }, - "node_modules/npm/node_modules/balanced-match": { - "version": "4.0.4", - "dev": true, - "inBundle": true, - "license": "MIT", - "peer": true, + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, "engines": { - "node": "18 || 20 || >=22" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/bin-links": { - "version": "6.0.0", + "node_modules/npm/node_modules/@npmcli/arborist": { + "version": "9.9.2", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "cmd-shim": "^8.0.0", - "npm-normalize-package-bin": "^5.0.0", + "@gar/promise-retry": "^1.0.0", + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/metavuln-calculator": "^9.0.2", + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/query": "^5.0.0", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.0", + "bin-links": "^6.0.0", + "cacache": "^20.0.1", + "common-ancestor-path": "^2.0.0", + "hosted-git-info": "^9.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^11.2.1", + "minimatch": "^10.0.3", + "nopt": "^9.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.0", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "pacote": "^21.0.2", + "parse-conflict-json": "^5.0.1", "proc-log": "^6.0.0", - "read-cmd-shim": "^6.0.0", - "write-file-atomic": "^7.0.0" + "proggy": "^4.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "semver": "^7.3.7", + "ssri": "^13.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^4.0.0" + }, + "bin": { + "arborist": "bin/index.js" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/binary-extensions": { - "version": "3.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/brace-expansion": { - "version": "5.0.5", + "node_modules/npm/node_modules/@npmcli/config": { + "version": "10.14.0", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true, + "license": "ISC", "dependencies": { - "balanced-match": "^4.0.2" + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "ci-info": "^4.0.0", + "ini": "^6.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "walk-up-path": "^4.0.0" }, "engines": { - "node": "18 || 20 || >=22" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/cacache": { - "version": "20.0.4", + "node_modules/npm/node_modules/@npmcli/fs": { + "version": "5.0.0", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "@npmcli/fs": "^5.0.0", - "fs-minipass": "^3.0.0", - "glob": "^13.0.0", - "lru-cache": "^11.1.0", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^13.0.0" + "semver": "^7.3.5" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/chalk": { - "version": "5.6.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/npm/node_modules/chownr": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/ci-info": { - "version": "4.4.0", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "inBundle": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/cidr-regex": { - "version": "5.0.4", - "dev": true, - "inBundle": true, - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=20" - } - }, - "node_modules/npm/node_modules/cmd-shim": { - "version": "8.0.0", + "node_modules/npm/node_modules/@npmcli/git": { + "version": "7.0.2", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "which": "^6.0.0" + }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/common-ancestor-path": { - "version": "2.0.0", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "peer": true, - "engines": { - "node": ">= 18" - } - }, - "node_modules/npm/node_modules/cssesc": { - "version": "3.0.0", + "node_modules/npm/node_modules/@npmcli/installed-package-contents": { + "version": "4.0.0", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" + }, "bin": { - "cssesc": "bin/cssesc" + "installed-package-contents": "bin/index.js" }, "engines": { - "node": ">=4" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/debug": { - "version": "4.4.3", + "node_modules/npm/node_modules/@npmcli/map-workspaces": { + "version": "5.0.3", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true, + "license": "ISC", "dependencies": { - "ms": "^2.1.3" + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "glob": "^13.0.0", + "minimatch": "^10.0.3" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/diff": { - "version": "8.0.4", + "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { + "version": "9.0.3", "dev": true, "inBundle": true, - "license": "BSD-3-Clause", - "peer": true, + "license": "ISC", + "dependencies": { + "cacache": "^20.0.0", + "json-parse-even-better-errors": "^5.0.0", + "pacote": "^21.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5" + }, "engines": { - "node": ">=0.3.1" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/env-paths": { - "version": "2.2.1", + "node_modules/npm/node_modules/@npmcli/name-from-folder": { + "version": "4.0.0", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true, + "license": "ISC", "engines": { - "node": ">=6" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/exponential-backoff": { - "version": "3.1.3", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "peer": true - }, - "node_modules/npm/node_modules/fastest-levenshtein": { - "version": "1.0.16", + "node_modules/npm/node_modules/@npmcli/node-gyp": { + "version": "5.0.0", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true, + "license": "ISC", "engines": { - "node": ">= 4.9.1" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/fs-minipass": { - "version": "3.0.3", + "node_modules/npm/node_modules/@npmcli/package-json": { + "version": "7.0.5", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "minipass": "^7.0.3" + "@npmcli/git": "^7.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^9.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.5.3", + "spdx-expression-parse": "^4.0.0" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/glob": { - "version": "13.0.6", + "node_modules/npm/node_modules/@npmcli/promise-spawn": { + "version": "9.0.1", "dev": true, "inBundle": true, - "license": "BlueOak-1.0.0", - "peer": true, + "license": "ISC", "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" + "which": "^6.0.0" }, "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/graceful-fs": { - "version": "4.2.11", - "dev": true, - "inBundle": true, - "license": "ISC", - "peer": true - }, - "node_modules/npm/node_modules/hosted-git-info": { - "version": "9.0.2", + "node_modules/npm/node_modules/@npmcli/query": { + "version": "5.0.0", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "lru-cache": "^11.1.0" + "postcss-selector-parser": "^7.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/http-cache-semantics": { - "version": "4.2.0", - "dev": true, - "inBundle": true, - "license": "BSD-2-Clause", - "peer": true - }, - "node_modules/npm/node_modules/http-proxy-agent": { - "version": "7.0.2", + "node_modules/npm/node_modules/@npmcli/redact": { + "version": "4.0.0", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true, - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, + "license": "ISC", "engines": { - "node": ">= 14" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/https-proxy-agent": { - "version": "7.0.6", + "node_modules/npm/node_modules/@npmcli/run-script": { + "version": "10.0.4", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true, + "license": "ISC", "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0" }, "engines": { - "node": ">= 14" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/iconv-lite": { - "version": "0.7.2", + "node_modules/npm/node_modules/@sigstore/bundle": { + "version": "4.0.0", "dev": true, "inBundle": true, - "license": "MIT", - "optional": true, - "peer": true, + "license": "Apache-2.0", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "@sigstore/protobuf-specs": "^0.5.0" }, "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/ignore-walk": { - "version": "8.0.0", + "node_modules/npm/node_modules/@sigstore/core": { + "version": "3.2.1", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "minimatch": "^10.0.3" - }, + "license": "Apache-2.0", "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/ini": { - "version": "6.0.0", + "node_modules/npm/node_modules/@sigstore/protobuf-specs": { + "version": "0.5.1", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true, + "license": "Apache-2.0", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/npm/node_modules/init-package-json": { - "version": "8.2.5", + "node_modules/npm/node_modules/@sigstore/sign": { + "version": "4.1.1", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true, + "license": "Apache-2.0", "dependencies": { - "@npmcli/package-json": "^7.0.0", - "npm-package-arg": "^13.0.0", - "promzard": "^3.0.1", - "read": "^5.0.1", - "semver": "^7.7.2", - "validate-npm-package-name": "^7.0.0" + "@gar/promise-retry": "^1.0.2", + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.4", + "proc-log": "^6.1.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/ip-address": { - "version": "10.1.0", + "node_modules/npm/node_modules/@sigstore/tuf": { + "version": "4.0.2", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.1.0" + }, "engines": { - "node": ">= 12" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/is-cidr": { - "version": "6.0.4", + "node_modules/npm/node_modules/@sigstore/verify": { + "version": "3.1.1", "dev": true, "inBundle": true, - "license": "BSD-2-Clause", - "peer": true, + "license": "Apache-2.0", "dependencies": { - "cidr-regex": "^5.0.4" + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.1", + "@sigstore/protobuf-specs": "^0.5.0" }, "engines": { - "node": ">=20" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/isexe": { - "version": "4.0.0", + "node_modules/npm/node_modules/@tufjs/canonical-json": { + "version": "2.0.0", "dev": true, "inBundle": true, - "license": "BlueOak-1.0.0", - "peer": true, + "license": "MIT", "engines": { - "node": ">=20" + "node": "^16.14.0 || >=18.0.0" } }, - "node_modules/npm/node_modules/json-parse-even-better-errors": { - "version": "5.0.0", + "node_modules/npm/node_modules/@tufjs/models": { + "version": "4.1.0", "dev": true, "inBundle": true, "license": "MIT", - "peer": true, + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.1.1" + }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/json-stringify-nice": { - "version": "1.1.4", + "node_modules/npm/node_modules/abbrev": { + "version": "4.0.0", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/jsonparse": { - "version": "1.3.1", + "node_modules/npm/node_modules/agent-base": { + "version": "7.1.4", "dev": true, - "engines": [ - "node >= 0.2.0" - ], "inBundle": true, "license": "MIT", - "peer": true + "engines": { + "node": ">= 14" + } }, - "node_modules/npm/node_modules/just-diff": { - "version": "6.0.2", + "node_modules/npm/node_modules/aproba": { + "version": "2.1.0", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true + "license": "ISC" }, - "node_modules/npm/node_modules/just-diff-apply": { - "version": "5.5.0", + "node_modules/npm/node_modules/archy": { + "version": "1.0.0", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true + "license": "MIT" }, - "node_modules/npm/node_modules/libnpmaccess": { - "version": "10.0.3", + "node_modules/npm/node_modules/balanced-match": { + "version": "4.0.4", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "npm-package-arg": "^13.0.0", - "npm-registry-fetch": "^19.0.0" - }, + "license": "MIT", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "18 || 20 || >=22" } }, - "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.1.6", + "node_modules/npm/node_modules/bin-links": { + "version": "6.0.2", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "@npmcli/arborist": "^9.4.3", - "@npmcli/installed-package-contents": "^4.0.0", - "binary-extensions": "^3.0.0", - "diff": "^8.0.2", - "minimatch": "^10.0.3", - "npm-package-arg": "^13.0.0", - "pacote": "^21.0.2", - "tar": "^7.5.1" + "cmd-shim": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "proc-log": "^6.0.0", + "read-cmd-shim": "^6.0.0", + "write-file-atomic": "^7.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/libnpmexec": { - "version": "10.2.6", + "node_modules/npm/node_modules/binary-extensions": { + "version": "3.1.0", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "@gar/promise-retry": "^1.0.0", - "@npmcli/arborist": "^9.4.3", - "@npmcli/package-json": "^7.0.0", - "@npmcli/run-script": "^10.0.0", - "ci-info": "^4.0.0", - "npm-package-arg": "^13.0.0", - "pacote": "^21.0.2", - "proc-log": "^6.0.0", - "read": "^5.0.1", - "semver": "^7.3.7", - "signal-exit": "^4.1.0", - "walk-up-path": "^4.0.0" - }, + "license": "MIT", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.20", + "node_modules/npm/node_modules/brace-expansion": { + "version": "5.0.9", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true, + "license": "MIT", "dependencies": { - "@npmcli/arborist": "^9.4.3" + "balanced-match": "^4.0.2" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "20 || >=22" } }, - "node_modules/npm/node_modules/libnpmorg": { - "version": "8.0.1", + "node_modules/npm/node_modules/cacache": { + "version": "20.0.4", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^19.0.0" + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/libnpmpack": { - "version": "9.1.6", + "node_modules/npm/node_modules/chalk": { + "version": "5.6.2", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "@npmcli/arborist": "^9.4.3", - "@npmcli/run-script": "^10.0.0", - "npm-package-arg": "^13.0.0", - "pacote": "^21.0.2" - }, + "license": "MIT", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/npm/node_modules/libnpmpublish": { - "version": "11.1.3", + "node_modules/npm/node_modules/chownr": { + "version": "3.0.0", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "@npmcli/package-json": "^7.0.0", - "ci-info": "^4.0.0", - "npm-package-arg": "^13.0.0", - "npm-registry-fetch": "^19.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.7", - "sigstore": "^4.0.0", - "ssri": "^13.0.0" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=18" } }, - "node_modules/npm/node_modules/libnpmsearch": { - "version": "9.0.1", + "node_modules/npm/node_modules/ci-info": { + "version": "4.4.0", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "npm-registry-fetch": "^19.0.0" - }, + "license": "MIT", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=8" } }, - "node_modules/npm/node_modules/libnpmteam": { - "version": "8.0.2", + "node_modules/npm/node_modules/cidr-regex": { + "version": "5.0.5", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^19.0.0" - }, + "license": "BSD-2-Clause", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=20" } }, - "node_modules/npm/node_modules/libnpmversion": { - "version": "8.0.3", + "node_modules/npm/node_modules/cmd-shim": { + "version": "8.0.0", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, - "dependencies": { - "@npmcli/git": "^7.0.0", - "@npmcli/run-script": "^10.0.0", - "json-parse-even-better-errors": "^5.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.7" - }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/lru-cache": { - "version": "11.3.5", + "node_modules/npm/node_modules/common-ancestor-path": { + "version": "2.0.0", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", - "peer": true, "engines": { - "node": "20 || >=22" + "node": ">= 18" } }, - "node_modules/npm/node_modules/make-fetch-happen": { - "version": "15.0.5", + "node_modules/npm/node_modules/cssesc": { + "version": "3.0.0", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "@gar/promise-retry": "^1.0.0", - "@npmcli/agent": "^4.0.0", - "@npmcli/redact": "^4.0.0", - "cacache": "^20.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^5.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^6.0.0", - "ssri": "^13.0.0" + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=4" } }, - "node_modules/npm/node_modules/minimatch": { - "version": "10.2.5", + "node_modules/npm/node_modules/debug": { + "version": "4.4.3", "dev": true, "inBundle": true, - "license": "BlueOak-1.0.0", - "peer": true, + "license": "MIT", "dependencies": { - "brace-expansion": "^5.0.5" + "ms": "^2.1.3" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=6.0" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/minipass": { - "version": "7.1.3", - "dev": true, - "inBundle": true, - "license": "BlueOak-1.0.0", - "peer": true, - "engines": { - "node": ">=16 || 14 >=14.17" + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/npm/node_modules/minipass-collect": { - "version": "2.0.1", + "node_modules/npm/node_modules/diff": { + "version": "8.0.4", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true, - "dependencies": { - "minipass": "^7.0.3" - }, + "license": "BSD-3-Clause", "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=0.3.1" } }, - "node_modules/npm/node_modules/minipass-fetch": { - "version": "5.0.2", + "node_modules/npm/node_modules/env-paths": { + "version": "2.2.1", "dev": true, "inBundle": true, "license": "MIT", - "peer": true, - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^2.0.0", - "minizlib": "^3.0.1" - }, "engines": { - "node": "^20.17.0 || >=22.9.0" - }, - "optionalDependencies": { - "iconv-lite": "^0.7.2" + "node": ">=6" } }, - "node_modules/npm/node_modules/minipass-flush": { - "version": "1.0.6", + "node_modules/npm/node_modules/exponential-backoff": { + "version": "3.1.3", "dev": true, "inBundle": true, - "license": "BlueOak-1.0.0", - "peer": true, - "dependencies": { - "minipass": "^7.1.3" - }, + "license": "Apache-2.0" + }, + "node_modules/npm/node_modules/fastest-levenshtein": { + "version": "1.0.16", + "dev": true, + "inBundle": true, + "license": "MIT", "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">= 4.9.1" } }, - "node_modules/npm/node_modules/minipass-pipeline": { - "version": "1.2.4", + "node_modules/npm/node_modules/fs-minipass": { + "version": "3.0.3", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "minipass": "^3.0.0" + "minipass": "^7.0.3" }, "engines": { - "node": ">=8" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", + "node_modules/npm/node_modules/glob": { + "version": "13.0.6", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true, + "license": "BlueOak-1.0.0", "dependencies": { - "yallist": "^4.0.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/npm/node_modules/minipass-pipeline/node_modules/yallist": { - "version": "4.0.0", + "node_modules/npm/node_modules/graceful-fs": { + "version": "4.2.11", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true + "license": "ISC" }, - "node_modules/npm/node_modules/minipass-sized": { - "version": "2.0.0", + "node_modules/npm/node_modules/hosted-git-info": { + "version": "9.0.3", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "minipass": "^7.1.2" + "lru-cache": "^11.1.0" }, "engines": { - "node": ">=8" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/minizlib": { - "version": "3.1.0", + "node_modules/npm/node_modules/http-cache-semantics": { + "version": "4.2.0", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true, - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } + "license": "BSD-2-Clause" }, - "node_modules/npm/node_modules/ms": { - "version": "2.1.3", + "node_modules/npm/node_modules/http-proxy-agent": { + "version": "7.0.2", "dev": true, "inBundle": true, "license": "MIT", - "peer": true - }, - "node_modules/npm/node_modules/mute-stream": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "ISC", - "peer": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">= 14" } }, - "node_modules/npm/node_modules/negotiator": { - "version": "1.0.0", + "node_modules/npm/node_modules/https-proxy-agent": { + "version": "7.0.6", "dev": true, "inBundle": true, "license": "MIT", - "peer": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, "engines": { - "node": ">= 0.6" + "node": ">= 14" } }, - "node_modules/npm/node_modules/node-gyp": { - "version": "12.3.0", + "node_modules/npm/node_modules/iconv-lite": { + "version": "0.7.2", "dev": true, "inBundle": true, "license": "MIT", - "peer": true, + "optional": true, "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "graceful-fs": "^4.2.6", - "nopt": "^9.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "tar": "^7.5.4", - "tinyglobby": "^0.2.12", - "undici": "^6.25.0", - "which": "^6.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/npm/node_modules/nopt": { - "version": "9.0.0", + "node_modules/npm/node_modules/ignore-walk": { + "version": "8.0.0", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "abbrev": "^4.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" + "minimatch": "^10.0.3" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/npm-audit-report": { - "version": "7.0.0", + "node_modules/npm/node_modules/ini": { + "version": "6.0.0", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/npm-bundled": { - "version": "5.0.0", + "node_modules/npm/node_modules/init-package-json": { + "version": "8.2.5", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "npm-normalize-package-bin": "^5.0.0" + "@npmcli/package-json": "^7.0.0", + "npm-package-arg": "^13.0.0", + "promzard": "^3.0.1", + "read": "^5.0.1", + "semver": "^7.7.2", + "validate-npm-package-name": "^7.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/npm-install-checks": { - "version": "8.0.0", + "node_modules/npm/node_modules/ip-address": { + "version": "10.5.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/npm/node_modules/is-cidr": { + "version": "6.0.4", "dev": true, "inBundle": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { - "semver": "^7.1.1" + "cidr-regex": "^5.0.4" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=20" } }, - "node_modules/npm/node_modules/npm-normalize-package-bin": { + "node_modules/npm/node_modules/isexe": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/npm/node_modules/json-parse-even-better-errors": { "version": "5.0.0", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true, + "license": "MIT", "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/npm-package-arg": { - "version": "13.0.2", + "node_modules/npm/node_modules/json-stringify-nice": { + "version": "1.1.4", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, - "dependencies": { - "hosted-git-info": "^9.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^7.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/npm/node_modules/npm-packlist": { - "version": "10.0.4", + "node_modules/npm/node_modules/jsonparse": { + "version": "1.3.1", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff": { + "version": "6.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff-apply": { + "version": "5.5.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/libnpmaccess": { + "version": "10.0.3", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "ignore-walk": "^8.0.0", - "proc-log": "^6.0.0" + "npm-package-arg": "^13.0.0", + "npm-registry-fetch": "^19.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/npm-pick-manifest": { - "version": "11.0.3", + "node_modules/npm/node_modules/libnpmdiff": { + "version": "8.1.13", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "npm-install-checks": "^8.0.0", - "npm-normalize-package-bin": "^5.0.0", + "@npmcli/arborist": "^9.9.2", + "@npmcli/installed-package-contents": "^4.0.0", + "binary-extensions": "^3.0.0", + "diff": "^8.0.2", + "minimatch": "^10.0.3", "npm-package-arg": "^13.0.0", - "semver": "^7.3.5" + "pacote": "^21.0.2", + "tar": "^7.5.1" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/npm-profile": { - "version": "12.0.1", + "node_modules/npm/node_modules/libnpmexec": { + "version": "10.3.3", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "npm-registry-fetch": "^19.0.0", - "proc-log": "^6.0.0" + "@gar/promise-retry": "^1.0.0", + "@npmcli/arborist": "^9.9.2", + "@npmcli/package-json": "^7.0.0", + "@npmcli/run-script": "^10.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2", + "proc-log": "^6.0.0", + "read": "^5.0.1", + "semver": "^7.3.7", + "signal-exit": "^4.1.0", + "walk-up-path": "^4.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/npm-registry-fetch": { - "version": "19.1.1", + "node_modules/npm/node_modules/libnpmfund": { + "version": "7.0.27", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "@npmcli/redact": "^4.0.0", - "jsonparse": "^1.3.1", - "make-fetch-happen": "^15.0.0", - "minipass": "^7.0.2", - "minipass-fetch": "^5.0.0", - "minizlib": "^3.0.1", - "npm-package-arg": "^13.0.0", - "proc-log": "^6.0.0" + "@npmcli/arborist": "^9.9.2" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/npm-user-validate": { - "version": "4.0.0", + "node_modules/npm/node_modules/libnpmorg": { + "version": "8.0.1", "dev": true, "inBundle": true, - "license": "BSD-2-Clause", - "peer": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^19.0.0" + }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/p-map": { - "version": "7.0.4", + "node_modules/npm/node_modules/libnpmpack": { + "version": "9.1.14", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.9.2", + "@npmcli/run-script": "^10.0.0", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/pacote": { - "version": "21.5.0", + "node_modules/npm/node_modules/libnpmpublish": { + "version": "11.2.1", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "@gar/promise-retry": "^1.0.0", - "@npmcli/git": "^7.0.0", - "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", - "@npmcli/promise-spawn": "^9.0.0", - "@npmcli/run-script": "^10.0.0", - "cacache": "^20.0.0", - "fs-minipass": "^3.0.0", - "minipass": "^7.0.2", + "ci-info": "^4.0.0", "npm-package-arg": "^13.0.0", - "npm-packlist": "^10.0.1", - "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", + "semver": "^7.3.7", "sigstore": "^4.0.0", - "ssri": "^13.0.0", - "tar": "^7.4.3" - }, - "bin": { - "pacote": "bin/index.js" + "ssri": "^13.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/parse-conflict-json": { - "version": "5.0.1", + "node_modules/npm/node_modules/libnpmsearch": { + "version": "9.0.1", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "json-parse-even-better-errors": "^5.0.0", - "just-diff": "^6.0.0", - "just-diff-apply": "^5.2.0" + "npm-registry-fetch": "^19.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/path-scurry": { - "version": "2.0.2", + "node_modules/npm/node_modules/libnpmteam": { + "version": "8.0.2", "dev": true, "inBundle": true, - "license": "BlueOak-1.0.0", - "peer": true, + "license": "ISC", "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" + "aproba": "^2.0.0", + "npm-registry-fetch": "^19.0.0" }, "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/postcss-selector-parser": { - "version": "7.1.1", + "node_modules/npm/node_modules/libnpmversion": { + "version": "8.0.4", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true, + "license": "ISC", "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" + "@npmcli/git": "^7.0.0", + "@npmcli/run-script": "^10.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.7" }, "engines": { - "node": ">=4" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/proc-log": { - "version": "6.1.0", + "node_modules/npm/node_modules/lru-cache": { + "version": "11.5.1", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true, + "license": "BlueOak-1.0.0", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "20 || >=22" } }, - "node_modules/npm/node_modules/proggy": { - "version": "4.0.0", + "node_modules/npm/node_modules/make-fetch-happen": { + "version": "15.0.6", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/promise-all-reject-late": { - "version": "1.0.1", + "node_modules/npm/node_modules/minimatch": { + "version": "10.2.5", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/npm/node_modules/promise-call-limit": { - "version": "3.0.2", + "node_modules/npm/node_modules/minipass": { + "version": "7.1.3", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" } }, - "node_modules/npm/node_modules/promzard": { - "version": "3.0.1", + "node_modules/npm/node_modules/minipass-collect": { + "version": "2.0.1", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "read": "^5.0.0" + "minipass": "^7.0.3" }, "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/qrcode-terminal": { - "version": "0.12.0", - "dev": true, - "inBundle": true, - "peer": true, - "bin": { - "qrcode-terminal": "bin/qrcode-terminal.js" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/npm/node_modules/read": { - "version": "5.0.1", + "node_modules/npm/node_modules/minipass-fetch": { + "version": "5.0.2", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true, + "license": "MIT", "dependencies": { - "mute-stream": "^3.0.0" + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" }, "engines": { "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" } }, - "node_modules/npm/node_modules/read-cmd-shim": { - "version": "6.0.0", + "node_modules/npm/node_modules/minipass-flush": { + "version": "1.0.6", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^7.1.3" + }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/npm/node_modules/safer-buffer": { - "version": "2.1.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/npm/node_modules/semver": { - "version": "7.7.4", + "node_modules/npm/node_modules/minipass-pipeline": { + "version": "1.2.4", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" + "dependencies": { + "minipass": "^3.0.0" }, "engines": { - "node": ">=10" + "node": ">=8" } }, - "node_modules/npm/node_modules/signal-exit": { - "version": "4.1.0", + "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/sigstore": { - "version": "4.1.0", - "dev": true, - "inBundle": true, - "license": "Apache-2.0", - "peer": true, "dependencies": { - "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", - "@sigstore/protobuf-specs": "^0.5.0", - "@sigstore/sign": "^4.1.0", - "@sigstore/tuf": "^4.0.1", - "@sigstore/verify": "^3.1.0" + "yallist": "^4.0.0" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=8" } }, - "node_modules/npm/node_modules/smart-buffer": { - "version": "4.2.0", + "node_modules/npm/node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } + "license": "ISC" }, - "node_modules/npm/node_modules/socks": { - "version": "2.8.7", + "node_modules/npm/node_modules/minipass-sized": { + "version": "2.0.0", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true, + "license": "ISC", "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" + "minipass": "^7.1.2" }, "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" + "node": ">=8" } }, - "node_modules/npm/node_modules/socks-proxy-agent": { - "version": "8.0.5", + "node_modules/npm/node_modules/minizlib": { + "version": "3.1.0", "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" + "minipass": "^7.1.2" }, "engines": { - "node": ">= 14" + "node": ">= 18" } }, - "node_modules/npm/node_modules/spdx-exceptions": { - "version": "2.5.0", + "node_modules/npm/node_modules/ms": { + "version": "2.1.3", "dev": true, "inBundle": true, - "license": "CC-BY-3.0", - "peer": true + "license": "MIT" }, - "node_modules/npm/node_modules/spdx-expression-parse": { - "version": "4.0.0", + "node_modules/npm/node_modules/mute-stream": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/negotiator": { + "version": "1.0.0", "dev": true, "inBundle": true, "license": "MIT", - "peer": true, - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" + "engines": { + "node": ">= 0.6" } }, - "node_modules/npm/node_modules/spdx-license-ids": { - "version": "3.0.23", + "node_modules/npm/node_modules/node-gyp": { + "version": "12.4.0", "dev": true, "inBundle": true, - "license": "CC0-1.0", - "peer": true + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } }, - "node_modules/npm/node_modules/ssri": { - "version": "13.0.1", + "node_modules/npm/node_modules/nopt": { + "version": "9.0.0", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, "dependencies": { - "minipass": "^7.0.3" + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/supports-color": { - "version": "10.2.2", + "node_modules/npm/node_modules/npm-audit-report": { + "version": "7.0.0", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true, + "license": "ISC", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/tar": { - "version": "7.5.13", + "node_modules/npm/node_modules/npm-bundled": { + "version": "5.0.0", "dev": true, "inBundle": true, - "license": "BlueOak-1.0.0", - "peer": true, + "license": "ISC", "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" + "npm-normalize-package-bin": "^5.0.0" }, "engines": { - "node": ">=18" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/text-table": { - "version": "0.2.0", + "node_modules/npm/node_modules/npm-install-checks": { + "version": "8.0.0", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } }, - "node_modules/npm/node_modules/tiny-relative-date": { - "version": "2.0.2", + "node_modules/npm/node_modules/npm-normalize-package-bin": { + "version": "5.0.0", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } }, - "node_modules/npm/node_modules/tinyglobby": { - "version": "0.2.16", + "node_modules/npm/node_modules/npm-package-arg": { + "version": "13.0.2", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true, + "license": "ISC", "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^7.0.0" }, "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", + "node_modules/npm/node_modules/npm-packlist": { + "version": "10.0.4", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "license": "ISC", + "dependencies": { + "ignore-walk": "^8.0.0", + "proc-log": "^6.0.0" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", + "node_modules/npm/node_modules/npm-pick-manifest": { + "version": "11.0.3", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" + "license": "ISC", + "dependencies": { + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", + "semver": "^7.3.5" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/treeverse": { - "version": "3.0.0", + "node_modules/npm/node_modules/npm-profile": { + "version": "12.0.2", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, + "dependencies": { + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.1.0" + }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/tuf-js": { - "version": "4.1.0", + "node_modules/npm/node_modules/npm-registry-fetch": { + "version": "19.1.1", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true, + "license": "ISC", "dependencies": { - "@tufjs/models": "4.1.0", - "debug": "^4.4.3", - "make-fetch-happen": "^15.0.1" + "@npmcli/redact": "^4.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^15.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/undici": { - "version": "6.25.0", + "node_modules/npm/node_modules/npm-user-validate": { + "version": "4.0.0", "dev": true, "inBundle": true, - "license": "MIT", - "peer": true, + "license": "BSD-2-Clause", "engines": { - "node": ">=18.17" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/util-deprecate": { - "version": "1.0.2", + "node_modules/npm/node_modules/p-map": { + "version": "7.0.4", "dev": true, "inBundle": true, "license": "MIT", - "peer": true + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/npm/node_modules/validate-npm-package-name": { - "version": "7.0.2", + "node_modules/npm/node_modules/pacote": { + "version": "21.5.1", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" + }, + "bin": { + "pacote": "bin/index.js" + }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/walk-up-path": { - "version": "4.0.0", + "node_modules/npm/node_modules/parse-conflict-json": { + "version": "5.0.1", "dev": true, "inBundle": true, "license": "ISC", - "peer": true, + "dependencies": { + "json-parse-even-better-errors": "^5.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, "engines": { - "node": "20 || >=22" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm/node_modules/which": { - "version": "6.0.1", + "node_modules/npm/node_modules/path-scurry": { + "version": "2.0.2", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true, + "license": "BlueOak-1.0.0", "dependencies": { - "isexe": "^4.0.0" - }, - "bin": { - "node-which": "bin/which.js" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/npm/node_modules/write-file-atomic": { - "version": "7.0.1", + "node_modules/npm/node_modules/postcss-selector-parser": { + "version": "7.1.4", "dev": true, "inBundle": true, - "license": "ISC", - "peer": true, + "license": "MIT", "dependencies": { - "signal-exit": "^4.0.1" + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=4" } }, - "node_modules/npm/node_modules/yallist": { - "version": "5.0.0", + "node_modules/npm/node_modules/proc-log": { + "version": "6.1.0", "dev": true, "inBundle": true, - "license": "BlueOak-1.0.0", - "peer": true, + "license": "ISC", "engines": { - "node": ">=18" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "node_modules/npm/node_modules/proggy": { + "version": "4.0.0", "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/on-exit-leak-free": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "inBundle": true, + "license": "ISC", "engines": { - "node": ">=14.0.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "node_modules/npm/node_modules/promise-all-reject-late": { + "version": "1.0.1", "dev": true, + "inBundle": true, "license": "ISC", - "peer": true, - "dependencies": { - "wrappy": "1" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "node_modules/npm/node_modules/promise-call-limit": { + "version": "3.0.2", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, + "inBundle": true, + "license": "ISC", "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "node_modules/npm/node_modules/promzard": { + "version": "3.0.1", "dev": true, - "license": "MIT", - "peer": true, + "inBundle": true, + "license": "ISC", "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" + "read": "^5.0.0" }, "engines": { - "node": ">= 0.8.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/p-each-series": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-3.0.0.tgz", - "integrity": "sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==", + "node_modules/npm/node_modules/qrcode-terminal": { + "version": "0.12.0", "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "inBundle": true, + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" } }, - "node_modules/p-filter": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-4.1.0.tgz", - "integrity": "sha512-37/tPdZ3oJwHaS3gNJdenCDB3Tz26i9sjhnguBtvN0vYlRIiDNnvTWkuh+0hETV9rLPdJ3rlL3yVOYPIAnM8rw==", + "node_modules/npm/node_modules/read": { + "version": "5.0.1", "dev": true, - "license": "MIT", - "peer": true, + "inBundle": true, + "license": "ISC", "dependencies": { - "p-map": "^7.0.1" + "mute-stream": "^3.0.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/p-is-promise": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", - "integrity": "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==", + "node_modules/npm/node_modules/read-cmd-shim": { + "version": "6.0.0", "dev": true, - "license": "MIT", - "peer": true, + "inBundle": true, + "license": "ISC", "engines": { - "node": ">=8" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/npm/node_modules/safer-buffer": { + "version": "2.1.2", "dev": true, + "inBundle": true, "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "optional": true }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "node_modules/npm/node_modules/semver": { + "version": "7.8.5", "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "node_modules/npm/node_modules/signal-exit": { + "version": "4.1.0", "dev": true, - "license": "MIT", - "peer": true, + "inBundle": true, + "license": "ISC", "engines": { - "node": ">=18" + "node": ">=14" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-reduce": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-2.1.0.tgz", - "integrity": "sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "node_modules/npm/node_modules/sigstore": { + "version": "4.1.1", "dev": true, - "license": "MIT", - "peer": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.1", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.1", + "@sigstore/tuf": "^4.0.2", + "@sigstore/verify": "^3.1.1" + }, "engines": { - "node": ">=6" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", - "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "node_modules/npm/node_modules/smart-buffer": { + "version": "4.2.0", "dev": true, + "inBundle": true, "license": "MIT", - "peer": true, - "dependencies": { - "callsites": "^3.0.0" - }, "engines": { - "node": ">=6" + "node": ">= 6.0.0", + "npm": ">= 3.0.0" } }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "node_modules/npm/node_modules/socks": { + "version": "2.8.9", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" + "inBundle": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 10.0.0", + "npm": ">= 3.0.0" } }, - "node_modules/parse-ms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", - "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "node_modules/npm/node_modules/socks-proxy-agent": { + "version": "8.0.5", "dev": true, + "inBundle": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 14" } }, - "node_modules/parse5": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", - "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", + "node_modules/npm/node_modules/spdx-exceptions": { + "version": "2.5.0", "dev": true, - "license": "MIT", - "peer": true + "inBundle": true, + "license": "CC-BY-3.0" }, - "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", - "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "node_modules/npm/node_modules/spdx-expression-parse": { + "version": "4.0.0", "dev": true, + "inBundle": true, "license": "MIT", - "peer": true, "dependencies": { - "parse5": "^6.0.1" + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" } }, - "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", - "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "node_modules/npm/node_modules/spdx-license-ids": { + "version": "3.0.23", "dev": true, - "license": "MIT", - "peer": true + "inBundle": true, + "license": "CC0-1.0" }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "node_modules/npm/node_modules/ssri": { + "version": "13.0.1", "dev": true, - "license": "MIT", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, "engines": { - "node": ">=8" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "node_modules/npm/node_modules/supports-color": { + "version": "10.2.2", "dev": true, + "inBundle": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=0.10.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/npm/node_modules/tar": { + "version": "7.5.22", "dev": true, - "license": "MIT", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "node_modules/npm/node_modules/text-table": { + "version": "0.2.0", "dev": true, - "license": "MIT", - "peer": true + "inBundle": true, + "license": "MIT" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "node_modules/npm/node_modules/tiny-relative-date": { + "version": "2.0.2", "dev": true, - "license": "BlueOak-1.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/tinyglobby": { + "version": "0.2.17", + "dev": true, + "inBundle": true, + "license": "MIT", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": ">=12.0.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-to-regexp": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.1.0.tgz", - "integrity": "sha512-Bqn3vc8CMHty6zuD+tG23s6v2kwxslHEhTj4eYaVKGIEB+YX/2wd0/rgXLFD9G9id9KCtbVy/3ZgmvZjpa0UdQ==", - "dev": true, - "engines": { - "node": ">=16" + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "node_modules/npm/node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", "dev": true, + "inBundle": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC", - "peer": true - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", "dev": true, + "inBundle": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { + "node_modules/npm/node_modules/treeverse": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", "dev": true, - "license": "MIT", - "peer": true, + "inBundle": true, + "license": "ISC", "engines": { - "node": ">=4" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/pino": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.5.0.tgz", - "integrity": "sha512-xSEmD4pLnV54t0NOUN16yCl7RIB1c5UUOse5HSyEXtBp+FgFQyPeDutc+Q2ZO7/22vImV7VfEjH/1zV2QuqvYw==", + "node_modules/npm/node_modules/tuf-js": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", "dependencies": { - "atomic-sleep": "^1.0.0", - "fast-redact": "^3.1.1", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^2.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^4.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", - "safe-stable-stringify": "^2.3.1", - "sonic-boom": "^4.0.1", - "thread-stream": "^3.0.0" + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-abstract-transport": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", - "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", - "dependencies": { - "split2": "^4.0.0" + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/pino-std-serializers": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", - "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==" - }, - "node_modules/pkg-conf": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-2.1.0.tgz", - "integrity": "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==", + "node_modules/npm/node_modules/undici": { + "version": "6.28.0", "dev": true, + "inBundle": true, "license": "MIT", - "peer": true, - "dependencies": { - "find-up": "^2.0.0", - "load-json-file": "^4.0.0" - }, "engines": { - "node": ">=4" + "node": ">=18.17" } }, - "node_modules/pkg-conf/node_modules/find-up": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", - "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==", + "node_modules/npm/node_modules/util-deprecate": { + "version": "1.0.2", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "locate-path": "^2.0.0" - }, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/validate-npm-package-name": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", "engines": { - "node": ">=4" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/pkg-conf/node_modules/locate-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", - "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", + "node_modules/npm/node_modules/walk-up-path": { + "version": "4.0.0", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "p-locate": "^2.0.0", - "path-exists": "^3.0.0" - }, + "inBundle": true, + "license": "ISC", "engines": { - "node": ">=4" + "node": "20 || >=22" } }, - "node_modules/pkg-conf/node_modules/p-limit": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", - "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "node_modules/npm/node_modules/which": { + "version": "6.0.1", "dev": true, - "license": "MIT", - "peer": true, + "inBundle": true, + "license": "ISC", "dependencies": { - "p-try": "^1.0.0" + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" }, "engines": { - "node": ">=4" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/pkg-conf/node_modules/p-locate": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", - "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", + "node_modules/npm/node_modules/write-file-atomic": { + "version": "7.0.1", "dev": true, - "license": "MIT", - "peer": true, + "inBundle": true, + "license": "ISC", "dependencies": { - "p-limit": "^1.1.0" + "signal-exit": "^4.0.1" }, "engines": { - "node": ">=4" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/pkg-conf/node_modules/p-try": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", - "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", + "node_modules/npm/node_modules/yallist": { + "version": "5.0.0", "dev": true, - "license": "MIT", - "peer": true, + "inBundle": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=4" + "node": ">=18" } }, - "node_modules/pkg-conf/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=4" + "node": ">=0.10.0" } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", "license": "MIT", - "peer": true, "engines": { - "node": ">= 0.8.0" + "node": ">=14.0.0" } }, - "node_modules/prettier": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", - "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", "dev": true, "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" + "dependencies": { + "mimic-fn": "^4.0.0" }, "engines": { - "node": ">=14" + "node": ">=12" }, "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pretty-ms": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", - "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "parse-ms": "^4.0.0" + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.8.0" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/process-warning": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.0.tgz", - "integrity": "sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==" - }, - "node_modules/proto-list": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", - "dev": true, - "license": "ISC", - "peer": true - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/p-each-series": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-3.0.0.tgz", + "integrity": "sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/punycode.js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "node_modules/p-event": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-6.0.1.tgz", + "integrity": "sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==", "dev": true, "license": "MIT", + "dependencies": { + "p-timeout": "^6.1.2" + }, "engines": { - "node": ">=6" + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" - }, - "node_modules/quick-lru": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", - "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", + "node_modules/p-filter": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-4.1.0.tgz", + "integrity": "sha512-37/tPdZ3oJwHaS3gNJdenCDB3Tz26i9sjhnguBtvN0vYlRIiDNnvTWkuh+0hETV9rLPdJ3rlL3yVOYPIAnM8rw==", "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "p-map": "^7.0.1" + }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { - "safe-buffer": "^5.1.0" + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "peer": true, + "license": "MIT", "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" + "p-limit": "^3.0.2" }, - "bin": { - "rc": "cli.js" + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "node_modules/p-map": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.1.1.tgz", + "integrity": "sha512-6Lh3jQPLthYujSrlw6ccWXBGZTkoTCz27Hd92g79LH/kjAbgoEu0t442cCCNbegPSJsA0lwDX8z0bcKtkpA32g==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=0.10.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-package-up": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz", - "integrity": "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw==", + "node_modules/p-reduce": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-3.0.0.tgz", + "integrity": "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "find-up-simple": "^1.0.1", - "read-pkg": "^10.0.0", - "type-fest": "^5.2.0" - }, "engines": { - "node": ">=20" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-package-up/node_modules/type-fest": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", - "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", "dev": true, - "license": "(MIT OR CC0-1.0)", - "peer": true, - "dependencies": { - "tagged-tag": "^1.0.0" - }, + "license": "MIT", "engines": { - "node": ">=20" + "node": ">=14.16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-pkg": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz", - "integrity": "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==", + "node_modules/p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@types/normalize-package-data": "^2.4.4", - "normalize-package-data": "^8.0.0", - "parse-json": "^8.3.0", - "type-fest": "^5.4.4", - "unicorn-magic": "^0.4.0" - }, "engines": { - "node": ">=20" + "node": ">=4" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=6" } }, - "node_modules/read-pkg/node_modules/parse-json": { + "node_modules/parse-json": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", @@ -9356,731 +7423,664 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-pkg/node_modules/parse-json/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", "dev": true, - "license": "(MIT OR CC0-1.0)", - "peer": true, + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-pkg/node_modules/type-fest": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", - "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "node_modules/parse5": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", "dev": true, - "license": "(MIT OR CC0-1.0)", - "peer": true, - "dependencies": { - "tagged-tag": "^1.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "MIT" }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "parse5": "^6.0.1" } }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/readable-stream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } + "license": "MIT" }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/real-require": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", "engines": { - "node": ">= 12.13.0" + "node": ">=8" } }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, "engines": { "node": ">=8" } }, - "node_modules/registry-auth-token": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", - "integrity": "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==", + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, - "license": "MIT", - "peer": true, + "license": "BlueOak-1.0.0", "dependencies": { - "@pnpm/npm-conf": "^3.0.2" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=14" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=0.10.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" + "engines": { + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=8" + "node": ">=4" } }, - "node_modules/resolve-global": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-global/-/resolve-global-1.0.0.tgz", - "integrity": "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==", - "dev": true, + "node_modules/pino": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.4.0.tgz", + "integrity": "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==", "license": "MIT", - "peer": true, "dependencies": { - "global-dirs": "^0.1.1" + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^1.0.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" }, - "engines": { - "node": ">=8" + "bin": { + "pino": "bin.js" } }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" + "dependencies": { + "split2": "^4.0.0" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, + "node_modules/pino-abstract-transport/node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "license": "ISC", - "peer": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": ">= 10.x" } }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pkg-conf": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-2.1.0.tgz", + "integrity": "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==", "dev": true, - "license": "ISC", - "peer": true, + "license": "MIT", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "find-up": "^2.0.0", + "load-json-file": "^4.0.0" }, "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=4" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "node_modules/pkg-conf/node_modules/find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], "license": "MIT", "dependencies": { - "queue-microtask": "^1.2.2" + "locate-path": "^2.0.0" + }, + "engines": { + "node": ">=4" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "node_modules/pkg-conf/node_modules/locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + }, "engines": { - "node": ">=10" + "node": ">=4" } }, - "node_modules/semantic-release": { - "version": "25.0.3", - "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.3.tgz", - "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", + "node_modules/pkg-conf/node_modules/p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@semantic-release/commit-analyzer": "^13.0.1", - "@semantic-release/error": "^4.0.0", - "@semantic-release/github": "^12.0.0", - "@semantic-release/npm": "^13.1.1", - "@semantic-release/release-notes-generator": "^14.1.0", - "aggregate-error": "^5.0.0", - "cosmiconfig": "^9.0.0", - "debug": "^4.0.0", - "env-ci": "^11.0.0", - "execa": "^9.0.0", - "figures": "^6.0.0", - "find-versions": "^6.0.0", - "get-stream": "^6.0.0", - "git-log-parser": "^1.2.0", - "hook-std": "^4.0.0", - "hosted-git-info": "^9.0.0", - "import-from-esm": "^2.0.0", - "lodash-es": "^4.17.21", - "marked": "^15.0.0", - "marked-terminal": "^7.3.0", - "micromatch": "^4.0.2", - "p-each-series": "^3.0.0", - "p-reduce": "^3.0.0", - "read-package-up": "^12.0.0", - "resolve-from": "^5.0.0", - "semver": "^7.3.2", - "signale": "^1.2.1", - "yargs": "^18.0.0" + "p-try": "^1.0.0" }, - "bin": { - "semantic-release": "bin/semantic-release.js" + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf/node_modules/p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^1.1.0" }, "engines": { - "node": "^22.14.0 || >= 24.10.0" + "node": ">=4" } }, - "node_modules/semantic-release/node_modules/@semantic-release/error": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", - "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "node_modules/pkg-conf/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=18" + "node": ">=4" } }, - "node_modules/semantic-release/node_modules/aggregate-error": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", - "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "clean-stack": "^5.2.0", - "indent-string": "^5.0.0" - }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.8.0" } }, - "node_modules/semantic-release/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", "dev": true, "license": "MIT", - "peer": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, "engines": { - "node": ">=12" + "node": ">=14" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/semantic-release/node_modules/clean-stack": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz", - "integrity": "sha512-TyUIUJgdFnCISzG5zu3291TAsE77ddchd0bepon1VVQrKLGKFED4iXFEDQ24mIPdPBbyE16PK3F8MYE1CmcBEQ==", + "node_modules/pretty-ms": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.1.tgz", + "integrity": "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "escape-string-regexp": "5.0.0" + "parse-ms": "^4.0.0" }, "engines": { - "node": ">=14.16" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } + "license": "MIT" }, - "node_modules/semantic-release/node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "node_modules/process-warning": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz", + "integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" + "node": ">= 20" }, "peerDependencies": { - "typescript": ">=4.9.5" + "kerberos": "^2.0.0" }, "peerDependenciesMeta": { - "typescript": { + "kerberos": { "optional": true } } }, - "node_modules/semantic-release/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6" } }, - "node_modules/semantic-release/node_modules/execa": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.3.1.tgz", - "integrity": "sha512-gdhefCCNy/8tpH/2+ajP9IQc14vXchNdd0weyzSJEFURhRMGncQ+zKFxwjAufIewPEJm9BPOaJnvg2UtlH2gPQ==", + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "cross-spawn": "^7.0.3", - "figures": "^6.1.0", - "get-stream": "^9.0.0", - "human-signals": "^8.0.0", - "is-plain-obj": "^4.1.0", - "is-stream": "^4.0.1", - "npm-run-path": "^5.2.0", - "pretty-ms": "^9.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^4.0.0", - "yoctocolors": "^2.0.0" - }, "engines": { - "node": "^18.19.0 || >=20.5.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" + "node": ">=6" } }, - "node_modules/semantic-release/node_modules/execa/node_modules/get-stream": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" + "hookified": "^2.1.1" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=20" } }, - "node_modules/semantic-release/node_modules/human-signals": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.0.tgz", - "integrity": "sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==", + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=18.18.0" - } + "license": "MIT" }, - "node_modules/semantic-release/node_modules/indent-string": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "bin": { + "rc": "cli.js" } }, - "node_modules/semantic-release/node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/read-package-up": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz", + "integrity": "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw==", "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "find-up-simple": "^1.0.1", + "read-pkg": "^10.0.0", + "type-fest": "^5.2.0" + }, "engines": { - "node": ">=12" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "node_modules/read-package-up/node_modules/type-fest": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz", + "integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==", "dev": true, - "license": "MIT", - "peer": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "node_modules/read-pkg": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz", + "integrity": "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "path-key": "^4.0.0" + "@types/normalize-package-data": "^2.4.4", + "normalize-package-data": "^8.0.0", + "parse-json": "^8.3.0", + "type-fest": "^5.4.4", + "unicorn-magic": "^0.4.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/p-reduce": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-3.0.0.tgz", - "integrity": "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==", + "node_modules/read-pkg/node_modules/type-fest": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz", + "integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==", "dev": true, - "license": "MIT", - "peer": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, "engines": { - "node": ">=12" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "node_modules/read-pkg/node_modules/unicorn-magic": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.1.tgz", + "integrity": "sha512-lzlXPoVB0Uy/FvTaUgX39RbixYiSKoc3JSoSf01+0c2B6FR3qdYIPN7Qjo/AV7n6sqLCk0509cT3LRj1oSZ1+A==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=12" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, - "license": "ISC", - "peer": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" } }, - "node_modules/semantic-release/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/registry-auth-token": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", + "integrity": "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "ansi-regex": "^6.2.2" + "@pnpm/npm-conf": "^3.0.2" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=14" } }, - "node_modules/semantic-release/node_modules/strip-final-newline": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "node_modules/semantic-release/node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" + "node": ">=0.10.0" } }, - "node_modules/semantic-release/node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, - "license": "ISC", - "peer": true, + "license": "MIT", "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" + "node": ">=8" } }, - "node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "dev": true, - "license": "ISC", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/semantic-release": { + "version": "25.0.9", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.9.tgz", + "integrity": "sha512-bxve7csK0/Txr++CkfrmV+X1r4jqiSOw2WsSad9E2S68R+ZfLBwDn8IceM8WfiOmKQIHgsQc1cNA8Dzg7U75pg==", + "dev": true, + "license": "MIT", "dependencies": { - "lru-cache": "^6.0.0" + "@semantic-release/commit-analyzer": "^13.0.1", + "@semantic-release/error": "^4.0.0", + "@semantic-release/github": "^12.0.0", + "@semantic-release/npm": "^13.1.1", + "@semantic-release/release-notes-generator": "^14.1.0", + "aggregate-error": "^5.0.0", + "cosmiconfig": "^9.0.0", + "debug": "^4.0.0", + "env-ci": "^11.0.0", + "execa": "^9.0.0", + "figures": "^6.0.0", + "find-versions": "^6.0.0", + "get-stream": "^6.0.0", + "git-log-parser": "^1.2.0", + "hook-std": "^4.0.0", + "hosted-git-info": "^9.0.0", + "import-from-esm": "^2.0.0", + "lodash-es": "^4.17.21", + "marked": "^15.0.0", + "marked-terminal": "^7.3.0", + "micromatch": "^4.0.2", + "p-each-series": "^3.0.0", + "p-reduce": "^3.0.0", + "read-package-up": "^12.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.3.2", + "signale": "^1.2.1", + "yargs": "^18.0.0" }, "bin": { - "semver": "bin/semver.js" + "semantic-release": "bin/semantic-release.js" }, "engines": { - "node": ">=10" + "node": "^22.14.0 || >= 24.10.0" } }, - "node_modules/semver-regex": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-4.0.5.tgz", - "integrity": "sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==", + "node_modules/semantic-release/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=12" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semver/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" + "bin": { + "semver": "bin/semver.js" }, "engines": { "node": ">=10" } }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "node_modules/semver-regex": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-4.0.5.tgz", + "integrity": "sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/shebang-command": { @@ -10107,12 +8107,17 @@ } }, "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, "license": "ISC", - "peer": true + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, "node_modules/signale": { "version": "1.4.0", @@ -10120,7 +8125,6 @@ "integrity": "sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chalk": "^2.3.2", "figures": "^2.0.0", @@ -10136,7 +8140,6 @@ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -10150,7 +8153,6 @@ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -10166,7 +8168,6 @@ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "color-name": "1.1.3" } @@ -10176,8 +8177,7 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/signale/node_modules/escape-string-regexp": { "version": "1.0.5", @@ -10185,7 +8185,6 @@ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.0" } @@ -10196,7 +8195,6 @@ "integrity": "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "escape-string-regexp": "^1.0.5" }, @@ -10210,7 +8208,6 @@ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -10221,7 +8218,6 @@ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -10230,13 +8226,14 @@ } }, "node_modules/sinon": { - "version": "19.0.2", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-19.0.2.tgz", - "integrity": "sha512-euuToqM+PjO4UgXeLETsfQiuoyPXlqFezr6YZDFwHR3t4qaX0fZUe1MfPMznTL5f8BWrVS89KduLdMUsxFCO6g==", + "version": "19.0.5", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-19.0.5.tgz", + "integrity": "sha512-r15s9/s+ub/d4bxNXqIUmwp6imVSdTorIRaxoecYjqTVLZ8RuoXr/4EDGwIBo6Waxn7f2gnURX9zuhAfCwaF6Q==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^13.0.2", + "@sinonjs/fake-timers": "^13.0.5", "@sinonjs/samsam": "^8.0.1", "diff": "^7.0.0", "nise": "^6.1.1", @@ -10247,22 +8244,12 @@ "url": "https://opencollective.com/sinon" } }, - "node_modules/sinon/node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", - "dev": true, - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/skin-tone": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" }, @@ -10271,9 +8258,10 @@ } }, "node_modules/sonic-boom": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", - "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", "dependencies": { "atomic-sleep": "^1.0.0" } @@ -10284,7 +8272,6 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10294,8 +8281,7 @@ "resolved": "https://registry.npmjs.org/spawn-error-forwarder/-/spawn-error-forwarder-1.0.0.tgz", "integrity": "sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/spdx-correct": { "version": "3.2.0", @@ -10303,7 +8289,6 @@ "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" @@ -10314,8 +8299,7 @@ "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", "dev": true, - "license": "CC-BY-3.0", - "peer": true + "license": "CC-BY-3.0" }, "node_modules/spdx-expression-parse": { "version": "3.0.1", @@ -10323,27 +8307,26 @@ "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "node_modules/spdx-license-ids": { - "version": "3.0.20", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.20.tgz", - "integrity": "sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==", + "version": "3.0.24", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.24.tgz", + "integrity": "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==", "dev": true, - "license": "CC0-1.0", - "peer": true + "license": "CC0-1.0" }, "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-1.0.0.tgz", + "integrity": "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg==", + "dev": true, "license": "ISC", - "engines": { - "node": ">= 10.x" + "dependencies": { + "through2": "~2.0.0" } }, "node_modules/stream-combiner2": { @@ -10352,21 +8335,27 @@ "integrity": "sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "duplexer2": "~0.1.0", "readable-stream": "^2.0.2" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "safe-buffer": "~5.2.0" + "safe-buffer": "~5.1.0" } }, "node_modules/string-argv": { @@ -10380,26 +8369,6 @@ } }, "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", @@ -10414,54 +8383,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -10475,16 +8396,12 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, "engines": { "node": ">=8" } @@ -10495,58 +8412,42 @@ "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } }, "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=6" - } - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "min-indent": "^1.0.0" + "node": ">=18" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, "node_modules/super-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.0.0.tgz", - "integrity": "sha512-CY8u7DtbvucKuquCmOFEKhr9Besln7n9uN8eFbwcoGYWXOMW07u2o8njWaiXt11ylS3qoGF55pILjRmPlbodyg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz", + "integrity": "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "function-timeout": "^1.0.1", + "make-asynchronous": "^1.0.1", "time-span": "^5.1.0" }, "engines": { @@ -10575,225 +8476,99 @@ "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" }, "engines": { - "node": ">=14.18" - }, - "funding": { - "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tagged-tag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", - "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/temp-dir": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", - "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=14.16" - } - }, - "node_modules/tempy": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.2.0.tgz", - "integrity": "sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "is-stream": "^3.0.0", - "temp-dir": "^3.0.0", - "type-fest": "^2.12.2", - "unique-string": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tempy/node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tempy/node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "peer": true, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/test-exclude": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", - "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^13.0.6", - "minimatch": "^10.2.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" + "node": ">=14.18" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, - "node_modules/test-exclude/node_modules/lru-cache": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", - "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "engines": { - "node": "20 || >=22" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "node_modules/temp-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", + "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/tempy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.2.0.tgz", + "integrity": "sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==", + "dev": true, + "license": "MIT", "dependencies": { - "brace-expansion": "^5.0.5" + "is-stream": "^3.0.0", + "temp-dir": "^3.0.0", + "type-fest": "^2.12.2", + "unique-string": "^3.0.0" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=14.16" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/test-exclude/node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "node_modules/tempy/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, + "license": "MIT", "engines": { - "node": "18 || 20 || >=22" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/text-extensions": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", - "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==", + "node_modules/tempy/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", "dev": true, - "license": "MIT", - "peer": true, + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=8" + "node": ">=12.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "node_modules/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } }, "node_modules/thenify": { "version": "3.3.1", @@ -10801,7 +8576,6 @@ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "any-promise": "^1.0.0" } @@ -10812,7 +8586,6 @@ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "thenify": ">= 3.1.0 < 4" }, @@ -10821,46 +8594,26 @@ } }, "node_modules/thread-stream": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", - "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", - "dependencies": { - "real-require": "^0.2.0" - } - }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/through2": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", - "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", - "dev": true, + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", "license": "MIT", - "peer": true, "dependencies": { - "readable-stream": "3" + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" } }, - "node_modules/through2/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" } }, "node_modules/time-span": { @@ -10869,7 +8622,6 @@ "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "convert-hrtime": "^5.0.0" }, @@ -10881,9 +8633,9 @@ } }, "node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -10891,12 +8643,11 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" @@ -10908,39 +8659,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -10960,7 +8678,6 @@ "integrity": "sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -10968,82 +8685,36 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/trim-newlines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", - "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/ts-api-utils": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", - "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18.12" }, "peerDependencies": { - "typescript": ">=4.2.0" + "typescript": ">=4.8.4" } }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", "dev": true, "license": "MIT", "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" + "esbuild": "~0.28.0" }, "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" + "tsx": "dist/cli.mjs" }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/ts-node/node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, - "license": "BSD-3-Clause", "engines": { - "node": ">=0.3.1" + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" } }, "node_modules/tunnel": { @@ -11052,7 +8723,6 @@ "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.6.11 <=0.7.0 || >=0.7.3" } @@ -11063,7 +8733,6 @@ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -11076,36 +8745,36 @@ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "dev": true, "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { - "node": ">=10" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/typedoc": { - "version": "0.28.19", - "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.19.tgz", - "integrity": "sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw==", + "version": "0.28.20", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.20.tgz", + "integrity": "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==", "dev": true, "license": "Apache-2.0", "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", - "markdown-it": "^14.1.1", + "markdown-it": "^14.3.0", "minimatch": "^10.2.5", - "yaml": "^2.8.3" + "yaml": "^2.9.0" }, "bin": { "typedoc": "bin/typedoc" @@ -11118,52 +8787,12 @@ "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" } }, - "node_modules/typedoc/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/typedoc/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/typedoc/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11173,30 +8802,27 @@ } }, "node_modules/typescript-eslint": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-7.18.0.tgz", - "integrity": "sha512-PonBkP603E3tt05lDkbOMyaxJjvKqQrXsnow72sVeOFINDE/qNmnnd+f9b4N+U7W6MXnnYyrhtmF2t08QWwUbA==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", + "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "7.18.0", - "@typescript-eslint/parser": "7.18.0", - "@typescript-eslint/utils": "7.18.0" + "@typescript-eslint/eslint-plugin": "8.71.2", + "@typescript-eslint/parser": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/uc.micro": { @@ -11213,7 +8839,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "bin": { "uglifyjs": "bin/uglifyjs" }, @@ -11222,23 +8847,20 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=20.18.1" } }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true, - "license": "MIT", - "peer": true + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { "version": "1.0.0", @@ -11246,20 +8868,18 @@ "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } }, "node_modules/unicorn-magic": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", - "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=20" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -11271,7 +8891,6 @@ "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "crypto-random-string": "^4.0.0" }, @@ -11287,8 +8906,7 @@ "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/universalify": { "version": "2.0.1", @@ -11296,7 +8914,6 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -11307,7 +8924,6 @@ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "punycode": "^2.1.0" } @@ -11318,7 +8934,6 @@ "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } @@ -11328,14 +8943,6 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { @@ -11346,22 +8953,11 @@ "license": "ISC", "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/v8-to-istanbul/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" } }, "node_modules/validate-npm-package-license": { @@ -11370,12 +8966,24 @@ "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, + "node_modules/verkit": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.5.0.tgz", + "integrity": "sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -11386,6 +8994,13 @@ "node": ">= 8" } }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -11402,13 +9017,28 @@ "node": ">= 8" } }, + "node_modules/which-command": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/which-command/-/which-command-0.1.0.tgz", + "integrity": "sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==", + "dev": true, + "license": "MIT", + "bin": { + "which-command": "cli.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/which-command?sponsor=1" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11418,23 +9048,14 @@ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/workerpool": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", - "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", - "dev": true, - "license": "Apache-2.0" + "license": "MIT" }, "node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", @@ -11447,94 +9068,39 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, - "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "peer": true, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -11543,21 +9109,12 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC", - "peer": true - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.4" } @@ -11572,17 +9129,10 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "dev": true, "license": "ISC", "bin": { @@ -11596,22 +9146,21 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz", + "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^8.0.1", + "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", + "string-width": "^8.2.1", "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yargs-parser": { @@ -11624,62 +9173,47 @@ "node": ">=12" } }, - "node_modules/yargs-unparser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", - "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "node_modules/yargs/node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", "dev": true, "license": "MIT", "dependencies": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=10" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/yargs/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "node_modules/yargs/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "dev": true, - "license": "MIT", + "license": "ISC", "engines": { - "node": ">=6" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yocto-queue": { @@ -11696,18 +9230,136 @@ } }, "node_modules/yoctocolors": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz", - "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "packages/binden": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "@binden/logger": "^3.0.0" + }, + "devDependencies": { + "@types/sinon": "^17.0.3", + "fast-json-stringify": "^7.0.1", + "node-fetch": "^3.3.2", + "sinon": "^19.0.2" + }, + "engines": { + "node": ">=24.14.1", + "npm": ">=11.11.0" + }, + "funding": { + "type": "Coinbase Commerce", + "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + } + }, + "packages/body": { + "name": "@binden/body", + "version": "3.0.0", + "license": "AGPL-3.0-only", + "devDependencies": { + "ajv": "^8.20.0", + "undici": "^7.11.0" + }, + "engines": { + "node": ">=24.14.1", + "npm": ">=11.11.0" + }, + "funding": { + "type": "Coinbase Commerce", + "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + }, + "peerDependencies": { + "binden": "^3.0.0" + } + }, + "packages/compression": { + "name": "@binden/compression", + "version": "3.0.0", + "license": "AGPL-3.0-only", + "devDependencies": { + "undici": "^8.3.0" + }, + "engines": { + "node": ">=24.14.1", + "npm": ">=11.11.0" + }, + "funding": { + "type": "Coinbase Commerce", + "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + }, + "peerDependencies": { + "binden": "^3.0.0" + } + }, + "packages/compression/node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "packages/logger": { + "name": "@binden/logger", + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "pino": "^10.3.1" + }, + "engines": { + "node": ">=24.14.1", + "npm": ">=11.11.0" + }, + "funding": { + "type": "Coinbase Commerce", + "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + } + }, + "packages/multipart": { + "name": "@binden/multipart", + "version": "3.0.0", + "license": "AGPL-3.0-only", + "dependencies": { + "busboy": "^1.6.0" + }, + "devDependencies": { + "undici": "^8.3.0" + }, + "engines": { + "node": ">=24.14.1", + "npm": ">=11.11.0" + }, + "funding": { + "type": "Coinbase Commerce", + "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" + }, + "peerDependencies": { + "@types/busboy": "^1.5.4", + "binden": "^3.0.0" + } + }, + "packages/multipart/node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } } } } diff --git a/package.json b/package.json new file mode 100644 index 00000000..870948e9 --- /dev/null +++ b/package.json @@ -0,0 +1,60 @@ +{ + "name": "binden-monorepo", + "private": true, + "description": "binden HTTP framework and its official packages", + "type": "module", + "workspaces": [ + "packages/logger", + "packages/binden", + "packages/body", + "packages/compression", + "packages/multipart" + ], + "engines": { + "node": ">=24.15.0", + "npm": ">=11.11.0" + }, + "scripts": { + "build": "npm run build --workspaces", + "ci:all": "npm ci && npm run release:check && npm run prettier && npm run commitlint && npm run eslint && npm run coverage && npm run pack:check && npm run docs:build", + "commitlint": "commitlint --verbose --from=origin/main", + "coverage": "c8 npm test", + "docs:build": "typedoc", + "eslint": "eslint .", + "eslint:fix": "eslint . --fix", + "install:clean": "npm install", + "pack:check": "npm pack --dry-run --workspaces", + "preinstall:clean": "rm -rf node_modules packages/*/node_modules package-lock.json", + "prepare": "husky && npm run build", + "prettier": "prettier -c .", + "prettier:write": "prettier -c -w .", + "release:check": "node scripts/release.mjs check", + "test": "npm test --workspaces --if-present" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/binden-js/binden.git" + }, + "author": "Sergey Bakulin ", + "devDependencies": { + "@commitlint/cli": "^21.2.3", + "@commitlint/config-conventional": "^21.2.3", + "@eslint/js": "^10.0.1", + "@semantic-release/changelog": "^7.0.0", + "@semantic-release/exec": "^7.1.0", + "@semantic-release/git": "^11.0.1", + "@types/node": "^24.19.2", + "c8": "^12.0.0", + "conventional-changelog-conventionalcommits": "^9.3.1", + "eslint": "^10.12.0", + "globals": "^17.13.0", + "husky": "^9.1.7", + "lint-staged": "^17.6.0", + "prettier": "^3.9.10", + "semantic-release": "^25.0.9", + "tsx": "^4.23.15", + "typedoc": "^0.28.20", + "typescript": "^6.0.3", + "typescript-eslint": "^8.71.2" + } +} diff --git a/packages/binden/.commitlintrc.json b/packages/binden/.commitlintrc.json deleted file mode 100644 index bb44fc44..00000000 --- a/packages/binden/.commitlintrc.json +++ /dev/null @@ -1 +0,0 @@ -{ "extends": "@binden/commitlint-config" } diff --git a/packages/binden/.eslintignore b/packages/binden/.eslintignore deleted file mode 100644 index 30e4cd53..00000000 --- a/packages/binden/.eslintignore +++ /dev/null @@ -1,5 +0,0 @@ -# Typescript compiled files -dist/ - -# Documentation -docs/ diff --git a/packages/binden/.gitignore b/packages/binden/.gitignore deleted file mode 100644 index 67045665..00000000 --- a/packages/binden/.gitignore +++ /dev/null @@ -1,104 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# 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 -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://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/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variables file -.env -.env.test - -# parcel-bundler cache (https://parceljs.org/) -.cache - -# Next.js build output -.next - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and *not* Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port diff --git a/packages/binden/.husky/.gitignore b/packages/binden/.husky/.gitignore deleted file mode 100644 index 31354ec1..00000000 --- a/packages/binden/.husky/.gitignore +++ /dev/null @@ -1 +0,0 @@ -_ diff --git a/packages/binden/.husky/commit-msg b/packages/binden/.husky/commit-msg deleted file mode 100755 index a78cc751..00000000 --- a/packages/binden/.husky/commit-msg +++ /dev/null @@ -1 +0,0 @@ -npx commitlint --edit $1 diff --git a/packages/binden/.lintstagedrc.json b/packages/binden/.lintstagedrc.json deleted file mode 100644 index 8cd68c57..00000000 --- a/packages/binden/.lintstagedrc.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "*.{js,cjs,mjs,ts,json,md,yaml,yml,html,css}": "prettier -w", - "*.ts": "eslint --fix" -} diff --git a/packages/binden/.mocharc.json b/packages/binden/.mocharc.json deleted file mode 100644 index b758d165..00000000 --- a/packages/binden/.mocharc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "bail": true, - "extension": "ts", - "full-trace": true, - "node-option": "loader=ts-node/esm", - "recursive": true, - "ui": "tdd" -} diff --git a/packages/binden/.npmignore b/packages/binden/.npmignore deleted file mode 100644 index c19ccacb..00000000 --- a/packages/binden/.npmignore +++ /dev/null @@ -1,52 +0,0 @@ -# Coverage directory used by c8 -coverage - -# GitHub folder -.github - -# Tests -test -dist/test/* - -# Documentation -docs - -# Commitlint config - https://commitlint.js.org/ -.commitlintrc.json - -# ESLint config - https://eslint.org/ -eslint.config.js - -# ESLint ignore - https://eslint.org/docs/user-guide/configuring#eslintignore -.eslintignore - -# husky config - https://github.com/typicode/husky -.husky - -# lint-staged config - https://github.com/okonet/lint-staged -.lintstagedrc.json - -# mocha config - https://github.com/mochajs/mocha -.mocharc.json - -# Prettier ignore -.prettierignore - -# semantic-release config - https://semantic-release.gitbook.io/semantic-release/ -.releaserc.json - -# Snyk policy file - https://snyk.io/ -.snyk - -# TypeScript config - https://www.typescriptlang.org/tsconfig -tsconfig.json - -# TypeDoc config - https://typedoc.org/ -typedoc.json - -# Code of Conduct -CODE_OF_CONDUCT.md - -# Source files -src/* -index.ts diff --git a/packages/binden/.prettierignore b/packages/binden/.prettierignore deleted file mode 100644 index 9bfa7ea7..00000000 --- a/packages/binden/.prettierignore +++ /dev/null @@ -1,2 +0,0 @@ -# Coverage directory used by c8 -coverage diff --git a/packages/binden/.releaserc.json b/packages/binden/.releaserc.json deleted file mode 100644 index 3122a54b..00000000 --- a/packages/binden/.releaserc.json +++ /dev/null @@ -1 +0,0 @@ -{ "extends": "@binden/semantic-release-config" } diff --git a/packages/binden/docs/.nojekyll b/packages/binden/docs/.nojekyll deleted file mode 100644 index e2ac6616..00000000 --- a/packages/binden/docs/.nojekyll +++ /dev/null @@ -1 +0,0 @@ -TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. \ No newline at end of file diff --git a/packages/binden/docs/assets/hierarchy.js b/packages/binden/docs/assets/hierarchy.js deleted file mode 100644 index a3e62523..00000000 --- a/packages/binden/docs/assets/hierarchy.js +++ /dev/null @@ -1,2 +0,0 @@ -window.hierarchyData = - "eJydlUtzgjAYRf/Lt46VRMJr13baGRfdtN05LhgImhETB+L04fjfO8QWiQYJ3bCACyeHj1wOUEmpakgWBBGMCKHIn6GIIuyFCNMZwlGMiDdbIqhYUbJMcSlqSA5AmoNItwwSmN9nGdupJ5HJnIsVINhwkUNCaIBgX5WQABeKVUWasXp6kb5bq20JCLIyrWtIQNX5pLl90t7SXFzzMq+YgGRBl0cEtEPvgWMS/cH1o1k9dQSfThhQX1MJNqT3ai0r/p0272TQuRseq0x8Tfe70la4zdkJfDpxRODPuoqPUigmnAd7ER+reXrJfne2fQuwiLrCW9XAs6i+pmLFHD11dqxkoGcZ+NeSJrrf8Da21YuoRe/9a+dq10THykVxIxfF13IGuN/tJrRVw15ouskNd9BqUmONMA4bJYxDw8kAWnVuws4m1Nhvz7L6SKuc5UMybXC0D9UjwrQ7o2usRWkYebYyvoD5vHiROS84y9+4yAYHdREfbRhjbRgbZd2zBIunK761JZ4xQ6cG+Vd1EC9Egad/BcYGGCwOl8awlBQhRoU8pDXT2/RTDel1oqMlSXRiR5cl0sH2FcgN3O+4jscfiGzr9Q=="; diff --git a/packages/binden/docs/assets/highlight.css b/packages/binden/docs/assets/highlight.css deleted file mode 100644 index 2191e11e..00000000 --- a/packages/binden/docs/assets/highlight.css +++ /dev/null @@ -1,146 +0,0 @@ -:root { - --light-hl-0: #795e26; - --dark-hl-0: #dcdcaa; - --light-hl-1: #000000; - --dark-hl-1: #d4d4d4; - --light-hl-2: #a31515; - --dark-hl-2: #ce9178; - --light-hl-3: #af00db; - --dark-hl-3: #c586c0; - --light-hl-4: #001080; - --dark-hl-4: #9cdcfe; - --light-hl-5: #0000ff; - --dark-hl-5: #569cd6; - --light-hl-6: #0070c1; - --dark-hl-6: #4fc1ff; - --light-hl-7: #098658; - --dark-hl-7: #b5cea8; - --light-hl-8: #267f99; - --dark-hl-8: #4ec9b0; - --light-hl-9: #008000; - --dark-hl-9: #6a9955; - --light-hl-10: #000000ff; - --dark-hl-10: #d4d4d4; - --light-hl-11: #ee0000; - --dark-hl-11: #d7ba7d; - --light-hl-12: #811f3f; - --dark-hl-12: #d16969; - --light-code-background: #ffffff; - --dark-code-background: #1e1e1e; -} - -@media (prefers-color-scheme: light) { - :root { - --hl-0: var(--light-hl-0); - --hl-1: var(--light-hl-1); - --hl-2: var(--light-hl-2); - --hl-3: var(--light-hl-3); - --hl-4: var(--light-hl-4); - --hl-5: var(--light-hl-5); - --hl-6: var(--light-hl-6); - --hl-7: var(--light-hl-7); - --hl-8: var(--light-hl-8); - --hl-9: var(--light-hl-9); - --hl-10: var(--light-hl-10); - --hl-11: var(--light-hl-11); - --hl-12: var(--light-hl-12); - --code-background: var(--light-code-background); - } -} - -@media (prefers-color-scheme: dark) { - :root { - --hl-0: var(--dark-hl-0); - --hl-1: var(--dark-hl-1); - --hl-2: var(--dark-hl-2); - --hl-3: var(--dark-hl-3); - --hl-4: var(--dark-hl-4); - --hl-5: var(--dark-hl-5); - --hl-6: var(--dark-hl-6); - --hl-7: var(--dark-hl-7); - --hl-8: var(--dark-hl-8); - --hl-9: var(--dark-hl-9); - --hl-10: var(--dark-hl-10); - --hl-11: var(--dark-hl-11); - --hl-12: var(--dark-hl-12); - --code-background: var(--dark-code-background); - } -} - -:root[data-theme="light"] { - --hl-0: var(--light-hl-0); - --hl-1: var(--light-hl-1); - --hl-2: var(--light-hl-2); - --hl-3: var(--light-hl-3); - --hl-4: var(--light-hl-4); - --hl-5: var(--light-hl-5); - --hl-6: var(--light-hl-6); - --hl-7: var(--light-hl-7); - --hl-8: var(--light-hl-8); - --hl-9: var(--light-hl-9); - --hl-10: var(--light-hl-10); - --hl-11: var(--light-hl-11); - --hl-12: var(--light-hl-12); - --code-background: var(--light-code-background); -} - -:root[data-theme="dark"] { - --hl-0: var(--dark-hl-0); - --hl-1: var(--dark-hl-1); - --hl-2: var(--dark-hl-2); - --hl-3: var(--dark-hl-3); - --hl-4: var(--dark-hl-4); - --hl-5: var(--dark-hl-5); - --hl-6: var(--dark-hl-6); - --hl-7: var(--dark-hl-7); - --hl-8: var(--dark-hl-8); - --hl-9: var(--dark-hl-9); - --hl-10: var(--dark-hl-10); - --hl-11: var(--dark-hl-11); - --hl-12: var(--dark-hl-12); - --code-background: var(--dark-code-background); -} - -.hl-0 { - color: var(--hl-0); -} -.hl-1 { - color: var(--hl-1); -} -.hl-2 { - color: var(--hl-2); -} -.hl-3 { - color: var(--hl-3); -} -.hl-4 { - color: var(--hl-4); -} -.hl-5 { - color: var(--hl-5); -} -.hl-6 { - color: var(--hl-6); -} -.hl-7 { - color: var(--hl-7); -} -.hl-8 { - color: var(--hl-8); -} -.hl-9 { - color: var(--hl-9); -} -.hl-10 { - color: var(--hl-10); -} -.hl-11 { - color: var(--hl-11); -} -.hl-12 { - color: var(--hl-12); -} -pre, -code { - background: var(--code-background); -} diff --git a/packages/binden/docs/assets/icons.js b/packages/binden/docs/assets/icons.js deleted file mode 100644 index 4d97ec0c..00000000 --- a/packages/binden/docs/assets/icons.js +++ /dev/null @@ -1,21 +0,0 @@ -(function () { - addIcons(); - function addIcons() { - if (document.readyState === "loading") - return document.addEventListener("DOMContentLoaded", addIcons); - const svg = document.body.appendChild( - document.createElementNS("http://www.w3.org/2000/svg", "svg"), - ); - svg.innerHTML = `MMNEPVFCICPMFPCPTTAAATR`; - svg.style.display = "none"; - if (location.protocol === "file:") updateUseElements(); - } - - function updateUseElements() { - document.querySelectorAll("use").forEach((el) => { - if (el.getAttribute("href").includes("#icon-")) { - el.setAttribute("href", el.getAttribute("href").replace(/.*#/, "#")); - } - }); - } -})(); diff --git a/packages/binden/docs/assets/icons.svg b/packages/binden/docs/assets/icons.svg deleted file mode 100644 index 50ad5799..00000000 --- a/packages/binden/docs/assets/icons.svg +++ /dev/null @@ -1 +0,0 @@ -MMNEPVFCICPMFPCPTTAAATR \ No newline at end of file diff --git a/packages/binden/docs/assets/main.js b/packages/binden/docs/assets/main.js deleted file mode 100644 index d639564f..00000000 --- a/packages/binden/docs/assets/main.js +++ /dev/null @@ -1,2399 +0,0 @@ -"use strict"; -window.translations = { - copy: "Copy", - copied: "Copied!", - normally_hidden: - "This member is normally hidden due to your filter settings.", - hierarchy_expand: "Expand", - hierarchy_collapse: "Collapse", -}; -("use strict"); -(() => { - var De = Object.create; - var le = Object.defineProperty; - var Fe = Object.getOwnPropertyDescriptor; - var Ne = Object.getOwnPropertyNames; - var Ve = Object.getPrototypeOf, - Be = Object.prototype.hasOwnProperty; - var qe = (t, e) => () => ( - e || t((e = { exports: {} }).exports, e), - e.exports - ); - var je = (t, e, n, r) => { - if ((e && typeof e == "object") || typeof e == "function") - for (let i of Ne(e)) - !Be.call(t, i) && - i !== n && - le(t, i, { - get: () => e[i], - enumerable: !(r = Fe(e, i)) || r.enumerable, - }); - return t; - }; - var $e = (t, e, n) => ( - (n = t != null ? De(Ve(t)) : {}), - je( - e || !t || !t.__esModule - ? le(n, "default", { value: t, enumerable: !0 }) - : n, - t, - ) - ); - var pe = qe((de, he) => { - (function () { - var t = function (e) { - var n = new t.Builder(); - return ( - n.pipeline.add(t.trimmer, t.stopWordFilter, t.stemmer), - n.searchPipeline.add(t.stemmer), - e.call(n, n), - n.build() - ); - }; - t.version = "2.3.9"; - ((t.utils = {}), - (t.utils.warn = (function (e) { - return function (n) { - e.console && console.warn && console.warn(n); - }; - })(this)), - (t.utils.asString = function (e) { - return e == null ? "" : e.toString(); - }), - (t.utils.clone = function (e) { - if (e == null) return e; - for ( - var n = Object.create(null), r = Object.keys(e), i = 0; - i < r.length; - i++ - ) { - var s = r[i], - o = e[s]; - if (Array.isArray(o)) { - n[s] = o.slice(); - continue; - } - if ( - typeof o == "string" || - typeof o == "number" || - typeof o == "boolean" - ) { - n[s] = o; - continue; - } - throw new TypeError( - "clone is not deep and does not support nested objects", - ); - } - return n; - }), - (t.FieldRef = function (e, n, r) { - ((this.docRef = e), (this.fieldName = n), (this._stringValue = r)); - }), - (t.FieldRef.joiner = "/"), - (t.FieldRef.fromString = function (e) { - var n = e.indexOf(t.FieldRef.joiner); - if (n === -1) throw "malformed field ref string"; - var r = e.slice(0, n), - i = e.slice(n + 1); - return new t.FieldRef(i, r, e); - }), - (t.FieldRef.prototype.toString = function () { - return ( - this._stringValue == null && - (this._stringValue = - this.fieldName + t.FieldRef.joiner + this.docRef), - this._stringValue - ); - })); - ((t.Set = function (e) { - if (((this.elements = Object.create(null)), e)) { - this.length = e.length; - for (var n = 0; n < this.length; n++) this.elements[e[n]] = !0; - } else this.length = 0; - }), - (t.Set.complete = { - intersect: function (e) { - return e; - }, - union: function () { - return this; - }, - contains: function () { - return !0; - }, - }), - (t.Set.empty = { - intersect: function () { - return this; - }, - union: function (e) { - return e; - }, - contains: function () { - return !1; - }, - }), - (t.Set.prototype.contains = function (e) { - return !!this.elements[e]; - }), - (t.Set.prototype.intersect = function (e) { - var n, - r, - i, - s = []; - if (e === t.Set.complete) return this; - if (e === t.Set.empty) return e; - (this.length < e.length - ? ((n = this), (r = e)) - : ((n = e), (r = this)), - (i = Object.keys(n.elements))); - for (var o = 0; o < i.length; o++) { - var a = i[o]; - a in r.elements && s.push(a); - } - return new t.Set(s); - }), - (t.Set.prototype.union = function (e) { - return e === t.Set.complete - ? t.Set.complete - : e === t.Set.empty - ? this - : new t.Set( - Object.keys(this.elements).concat(Object.keys(e.elements)), - ); - }), - (t.idf = function (e, n) { - var r = 0; - for (var i in e) i != "_index" && (r += Object.keys(e[i]).length); - var s = (n - r + 0.5) / (r + 0.5); - return Math.log(1 + Math.abs(s)); - }), - (t.Token = function (e, n) { - ((this.str = e || ""), (this.metadata = n || {})); - }), - (t.Token.prototype.toString = function () { - return this.str; - }), - (t.Token.prototype.update = function (e) { - return ((this.str = e(this.str, this.metadata)), this); - }), - (t.Token.prototype.clone = function (e) { - return ( - (e = - e || - function (n) { - return n; - }), - new t.Token(e(this.str, this.metadata), this.metadata) - ); - })); - ((t.tokenizer = function (e, n) { - if (e == null || e == null) return []; - if (Array.isArray(e)) - return e.map(function (m) { - return new t.Token( - t.utils.asString(m).toLowerCase(), - t.utils.clone(n), - ); - }); - for ( - var r = e.toString().toLowerCase(), - i = r.length, - s = [], - o = 0, - a = 0; - o <= i; - o++ - ) { - var l = r.charAt(o), - c = o - a; - if (l.match(t.tokenizer.separator) || o == i) { - if (c > 0) { - var d = t.utils.clone(n) || {}; - ((d.position = [a, c]), - (d.index = s.length), - s.push(new t.Token(r.slice(a, o), d))); - } - a = o + 1; - } - } - return s; - }), - (t.tokenizer.separator = /[\s\-]+/)); - ((t.Pipeline = function () { - this._stack = []; - }), - (t.Pipeline.registeredFunctions = Object.create(null)), - (t.Pipeline.registerFunction = function (e, n) { - (n in this.registeredFunctions && - t.utils.warn("Overwriting existing registered function: " + n), - (e.label = n), - (t.Pipeline.registeredFunctions[e.label] = e)); - }), - (t.Pipeline.warnIfFunctionNotRegistered = function (e) { - var n = e.label && e.label in this.registeredFunctions; - n || - t.utils.warn( - `Function is not registered with pipeline. This may cause problems when serialising the index. -`, - e, - ); - }), - (t.Pipeline.load = function (e) { - var n = new t.Pipeline(); - return ( - e.forEach(function (r) { - var i = t.Pipeline.registeredFunctions[r]; - if (i) n.add(i); - else throw new Error("Cannot load unregistered function: " + r); - }), - n - ); - }), - (t.Pipeline.prototype.add = function () { - var e = Array.prototype.slice.call(arguments); - e.forEach(function (n) { - (t.Pipeline.warnIfFunctionNotRegistered(n), this._stack.push(n)); - }, this); - }), - (t.Pipeline.prototype.after = function (e, n) { - t.Pipeline.warnIfFunctionNotRegistered(n); - var r = this._stack.indexOf(e); - if (r == -1) throw new Error("Cannot find existingFn"); - ((r = r + 1), this._stack.splice(r, 0, n)); - }), - (t.Pipeline.prototype.before = function (e, n) { - t.Pipeline.warnIfFunctionNotRegistered(n); - var r = this._stack.indexOf(e); - if (r == -1) throw new Error("Cannot find existingFn"); - this._stack.splice(r, 0, n); - }), - (t.Pipeline.prototype.remove = function (e) { - var n = this._stack.indexOf(e); - n != -1 && this._stack.splice(n, 1); - }), - (t.Pipeline.prototype.run = function (e) { - for (var n = this._stack.length, r = 0; r < n; r++) { - for (var i = this._stack[r], s = [], o = 0; o < e.length; o++) { - var a = i(e[o], o, e); - if (!(a == null || a === "")) - if (Array.isArray(a)) - for (var l = 0; l < a.length; l++) s.push(a[l]); - else s.push(a); - } - e = s; - } - return e; - }), - (t.Pipeline.prototype.runString = function (e, n) { - var r = new t.Token(e, n); - return this.run([r]).map(function (i) { - return i.toString(); - }); - }), - (t.Pipeline.prototype.reset = function () { - this._stack = []; - }), - (t.Pipeline.prototype.toJSON = function () { - return this._stack.map(function (e) { - return (t.Pipeline.warnIfFunctionNotRegistered(e), e.label); - }); - })); - ((t.Vector = function (e) { - ((this._magnitude = 0), (this.elements = e || [])); - }), - (t.Vector.prototype.positionForIndex = function (e) { - if (this.elements.length == 0) return 0; - for ( - var n = 0, - r = this.elements.length / 2, - i = r - n, - s = Math.floor(i / 2), - o = this.elements[s * 2]; - i > 1 && (o < e && (n = s), o > e && (r = s), o != e); - ) - ((i = r - n), - (s = n + Math.floor(i / 2)), - (o = this.elements[s * 2])); - if (o == e || o > e) return s * 2; - if (o < e) return (s + 1) * 2; - }), - (t.Vector.prototype.insert = function (e, n) { - this.upsert(e, n, function () { - throw "duplicate index"; - }); - }), - (t.Vector.prototype.upsert = function (e, n, r) { - this._magnitude = 0; - var i = this.positionForIndex(e); - this.elements[i] == e - ? (this.elements[i + 1] = r(this.elements[i + 1], n)) - : this.elements.splice(i, 0, e, n); - }), - (t.Vector.prototype.magnitude = function () { - if (this._magnitude) return this._magnitude; - for (var e = 0, n = this.elements.length, r = 1; r < n; r += 2) { - var i = this.elements[r]; - e += i * i; - } - return (this._magnitude = Math.sqrt(e)); - }), - (t.Vector.prototype.dot = function (e) { - for ( - var n = 0, - r = this.elements, - i = e.elements, - s = r.length, - o = i.length, - a = 0, - l = 0, - c = 0, - d = 0; - c < s && d < o; - ) - ((a = r[c]), - (l = i[d]), - a < l - ? (c += 2) - : a > l - ? (d += 2) - : a == l && ((n += r[c + 1] * i[d + 1]), (c += 2), (d += 2))); - return n; - }), - (t.Vector.prototype.similarity = function (e) { - return this.dot(e) / this.magnitude() || 0; - }), - (t.Vector.prototype.toArray = function () { - for ( - var e = new Array(this.elements.length / 2), n = 1, r = 0; - n < this.elements.length; - n += 2, r++ - ) - e[r] = this.elements[n]; - return e; - }), - (t.Vector.prototype.toJSON = function () { - return this.elements; - })); - ((t.stemmer = (function () { - var e = { - ational: "ate", - tional: "tion", - enci: "ence", - anci: "ance", - izer: "ize", - bli: "ble", - alli: "al", - entli: "ent", - eli: "e", - ousli: "ous", - ization: "ize", - ation: "ate", - ator: "ate", - alism: "al", - iveness: "ive", - fulness: "ful", - ousness: "ous", - aliti: "al", - iviti: "ive", - biliti: "ble", - logi: "log", - }, - n = { - icate: "ic", - ative: "", - alize: "al", - iciti: "ic", - ical: "ic", - ful: "", - ness: "", - }, - r = "[^aeiou]", - i = "[aeiouy]", - s = r + "[^aeiouy]*", - o = i + "[aeiou]*", - a = "^(" + s + ")?" + o + s, - l = "^(" + s + ")?" + o + s + "(" + o + ")?$", - c = "^(" + s + ")?" + o + s + o + s, - d = "^(" + s + ")?" + i, - m = new RegExp(a), - p = new RegExp(c), - b = new RegExp(l), - v = new RegExp(d), - L = /^(.+?)(ss|i)es$/, - f = /^(.+?)([^s])s$/, - y = /^(.+?)eed$/, - S = /^(.+?)(ed|ing)$/, - w = /.$/, - k = /(at|bl|iz)$/, - _ = new RegExp("([^aeiouylsz])\\1$"), - q = new RegExp("^" + s + i + "[^aeiouwxy]$"), - F = /^(.+?[^aeiou])y$/, - j = - /^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$/, - $ = /^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$/, - N = - /^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$/, - z = /^(.+?)(s|t)(ion)$/, - Q = /^(.+?)e$/, - W = /ll$/, - U = new RegExp("^" + s + i + "[^aeiouwxy]$"), - V = function (u) { - var g, P, T, h, x, O, R; - if (u.length < 3) return u; - if ( - ((T = u.substr(0, 1)), - T == "y" && (u = T.toUpperCase() + u.substr(1)), - (h = L), - (x = f), - h.test(u) - ? (u = u.replace(h, "$1$2")) - : x.test(u) && (u = u.replace(x, "$1$2")), - (h = y), - (x = S), - h.test(u)) - ) { - var E = h.exec(u); - ((h = m), h.test(E[1]) && ((h = w), (u = u.replace(h, "")))); - } else if (x.test(u)) { - var E = x.exec(u); - ((g = E[1]), - (x = v), - x.test(g) && - ((u = g), - (x = k), - (O = _), - (R = q), - x.test(u) - ? (u = u + "e") - : O.test(u) - ? ((h = w), (u = u.replace(h, ""))) - : R.test(u) && (u = u + "e"))); - } - if (((h = F), h.test(u))) { - var E = h.exec(u); - ((g = E[1]), (u = g + "i")); - } - if (((h = j), h.test(u))) { - var E = h.exec(u); - ((g = E[1]), (P = E[2]), (h = m), h.test(g) && (u = g + e[P])); - } - if (((h = $), h.test(u))) { - var E = h.exec(u); - ((g = E[1]), (P = E[2]), (h = m), h.test(g) && (u = g + n[P])); - } - if (((h = N), (x = z), h.test(u))) { - var E = h.exec(u); - ((g = E[1]), (h = p), h.test(g) && (u = g)); - } else if (x.test(u)) { - var E = x.exec(u); - ((g = E[1] + E[2]), (x = p), x.test(g) && (u = g)); - } - if (((h = Q), h.test(u))) { - var E = h.exec(u); - ((g = E[1]), - (h = p), - (x = b), - (O = U), - (h.test(g) || (x.test(g) && !O.test(g))) && (u = g)); - } - return ( - (h = W), - (x = p), - h.test(u) && x.test(u) && ((h = w), (u = u.replace(h, ""))), - T == "y" && (u = T.toLowerCase() + u.substr(1)), - u - ); - }; - return function (M) { - return M.update(V); - }; - })()), - t.Pipeline.registerFunction(t.stemmer, "stemmer")); - ((t.generateStopWordFilter = function (e) { - var n = e.reduce(function (r, i) { - return ((r[i] = i), r); - }, {}); - return function (r) { - if (r && n[r.toString()] !== r.toString()) return r; - }; - }), - (t.stopWordFilter = t.generateStopWordFilter([ - "a", - "able", - "about", - "across", - "after", - "all", - "almost", - "also", - "am", - "among", - "an", - "and", - "any", - "are", - "as", - "at", - "be", - "because", - "been", - "but", - "by", - "can", - "cannot", - "could", - "dear", - "did", - "do", - "does", - "either", - "else", - "ever", - "every", - "for", - "from", - "get", - "got", - "had", - "has", - "have", - "he", - "her", - "hers", - "him", - "his", - "how", - "however", - "i", - "if", - "in", - "into", - "is", - "it", - "its", - "just", - "least", - "let", - "like", - "likely", - "may", - "me", - "might", - "most", - "must", - "my", - "neither", - "no", - "nor", - "not", - "of", - "off", - "often", - "on", - "only", - "or", - "other", - "our", - "own", - "rather", - "said", - "say", - "says", - "she", - "should", - "since", - "so", - "some", - "than", - "that", - "the", - "their", - "them", - "then", - "there", - "these", - "they", - "this", - "tis", - "to", - "too", - "twas", - "us", - "wants", - "was", - "we", - "were", - "what", - "when", - "where", - "which", - "while", - "who", - "whom", - "why", - "will", - "with", - "would", - "yet", - "you", - "your", - ])), - t.Pipeline.registerFunction(t.stopWordFilter, "stopWordFilter")); - ((t.trimmer = function (e) { - return e.update(function (n) { - return n.replace(/^\W+/, "").replace(/\W+$/, ""); - }); - }), - t.Pipeline.registerFunction(t.trimmer, "trimmer")); - ((t.TokenSet = function () { - ((this.final = !1), - (this.edges = {}), - (this.id = t.TokenSet._nextId), - (t.TokenSet._nextId += 1)); - }), - (t.TokenSet._nextId = 1), - (t.TokenSet.fromArray = function (e) { - for ( - var n = new t.TokenSet.Builder(), r = 0, i = e.length; - r < i; - r++ - ) - n.insert(e[r]); - return (n.finish(), n.root); - }), - (t.TokenSet.fromClause = function (e) { - return "editDistance" in e - ? t.TokenSet.fromFuzzyString(e.term, e.editDistance) - : t.TokenSet.fromString(e.term); - }), - (t.TokenSet.fromFuzzyString = function (e, n) { - for ( - var r = new t.TokenSet(), - i = [{ node: r, editsRemaining: n, str: e }]; - i.length; - ) { - var s = i.pop(); - if (s.str.length > 0) { - var o = s.str.charAt(0), - a; - (o in s.node.edges - ? (a = s.node.edges[o]) - : ((a = new t.TokenSet()), (s.node.edges[o] = a)), - s.str.length == 1 && (a.final = !0), - i.push({ - node: a, - editsRemaining: s.editsRemaining, - str: s.str.slice(1), - })); - } - if (s.editsRemaining != 0) { - if ("*" in s.node.edges) var l = s.node.edges["*"]; - else { - var l = new t.TokenSet(); - s.node.edges["*"] = l; - } - if ( - (s.str.length == 0 && (l.final = !0), - i.push({ - node: l, - editsRemaining: s.editsRemaining - 1, - str: s.str, - }), - s.str.length > 1 && - i.push({ - node: s.node, - editsRemaining: s.editsRemaining - 1, - str: s.str.slice(1), - }), - s.str.length == 1 && (s.node.final = !0), - s.str.length >= 1) - ) { - if ("*" in s.node.edges) var c = s.node.edges["*"]; - else { - var c = new t.TokenSet(); - s.node.edges["*"] = c; - } - (s.str.length == 1 && (c.final = !0), - i.push({ - node: c, - editsRemaining: s.editsRemaining - 1, - str: s.str.slice(1), - })); - } - if (s.str.length > 1) { - var d = s.str.charAt(0), - m = s.str.charAt(1), - p; - (m in s.node.edges - ? (p = s.node.edges[m]) - : ((p = new t.TokenSet()), (s.node.edges[m] = p)), - s.str.length == 1 && (p.final = !0), - i.push({ - node: p, - editsRemaining: s.editsRemaining - 1, - str: d + s.str.slice(2), - })); - } - } - } - return r; - }), - (t.TokenSet.fromString = function (e) { - for ( - var n = new t.TokenSet(), r = n, i = 0, s = e.length; - i < s; - i++ - ) { - var o = e[i], - a = i == s - 1; - if (o == "*") ((n.edges[o] = n), (n.final = a)); - else { - var l = new t.TokenSet(); - ((l.final = a), (n.edges[o] = l), (n = l)); - } - } - return r; - }), - (t.TokenSet.prototype.toArray = function () { - for (var e = [], n = [{ prefix: "", node: this }]; n.length; ) { - var r = n.pop(), - i = Object.keys(r.node.edges), - s = i.length; - r.node.final && (r.prefix.charAt(0), e.push(r.prefix)); - for (var o = 0; o < s; o++) { - var a = i[o]; - n.push({ prefix: r.prefix.concat(a), node: r.node.edges[a] }); - } - } - return e; - }), - (t.TokenSet.prototype.toString = function () { - if (this._str) return this._str; - for ( - var e = this.final ? "1" : "0", - n = Object.keys(this.edges).sort(), - r = n.length, - i = 0; - i < r; - i++ - ) { - var s = n[i], - o = this.edges[s]; - e = e + s + o.id; - } - return e; - }), - (t.TokenSet.prototype.intersect = function (e) { - for ( - var n = new t.TokenSet(), - r = void 0, - i = [{ qNode: e, output: n, node: this }]; - i.length; - ) { - r = i.pop(); - for ( - var s = Object.keys(r.qNode.edges), - o = s.length, - a = Object.keys(r.node.edges), - l = a.length, - c = 0; - c < o; - c++ - ) - for (var d = s[c], m = 0; m < l; m++) { - var p = a[m]; - if (p == d || d == "*") { - var b = r.node.edges[p], - v = r.qNode.edges[d], - L = b.final && v.final, - f = void 0; - (p in r.output.edges - ? ((f = r.output.edges[p]), (f.final = f.final || L)) - : ((f = new t.TokenSet()), - (f.final = L), - (r.output.edges[p] = f)), - i.push({ qNode: v, output: f, node: b })); - } - } - } - return n; - }), - (t.TokenSet.Builder = function () { - ((this.previousWord = ""), - (this.root = new t.TokenSet()), - (this.uncheckedNodes = []), - (this.minimizedNodes = {})); - }), - (t.TokenSet.Builder.prototype.insert = function (e) { - var n, - r = 0; - if (e < this.previousWord) - throw new Error("Out of order word insertion"); - for ( - var i = 0; - i < e.length && - i < this.previousWord.length && - e[i] == this.previousWord[i]; - i++ - ) - r++; - (this.minimize(r), - this.uncheckedNodes.length == 0 - ? (n = this.root) - : (n = - this.uncheckedNodes[this.uncheckedNodes.length - 1].child)); - for (var i = r; i < e.length; i++) { - var s = new t.TokenSet(), - o = e[i]; - ((n.edges[o] = s), - this.uncheckedNodes.push({ parent: n, char: o, child: s }), - (n = s)); - } - ((n.final = !0), (this.previousWord = e)); - }), - (t.TokenSet.Builder.prototype.finish = function () { - this.minimize(0); - }), - (t.TokenSet.Builder.prototype.minimize = function (e) { - for (var n = this.uncheckedNodes.length - 1; n >= e; n--) { - var r = this.uncheckedNodes[n], - i = r.child.toString(); - (i in this.minimizedNodes - ? (r.parent.edges[r.char] = this.minimizedNodes[i]) - : ((r.child._str = i), (this.minimizedNodes[i] = r.child)), - this.uncheckedNodes.pop()); - } - })); - ((t.Index = function (e) { - ((this.invertedIndex = e.invertedIndex), - (this.fieldVectors = e.fieldVectors), - (this.tokenSet = e.tokenSet), - (this.fields = e.fields), - (this.pipeline = e.pipeline)); - }), - (t.Index.prototype.search = function (e) { - return this.query(function (n) { - var r = new t.QueryParser(e, n); - r.parse(); - }); - }), - (t.Index.prototype.query = function (e) { - for ( - var n = new t.Query(this.fields), - r = Object.create(null), - i = Object.create(null), - s = Object.create(null), - o = Object.create(null), - a = Object.create(null), - l = 0; - l < this.fields.length; - l++ - ) - i[this.fields[l]] = new t.Vector(); - e.call(n, n); - for (var l = 0; l < n.clauses.length; l++) { - var c = n.clauses[l], - d = null, - m = t.Set.empty; - c.usePipeline - ? (d = this.pipeline.runString(c.term, { fields: c.fields })) - : (d = [c.term]); - for (var p = 0; p < d.length; p++) { - var b = d[p]; - c.term = b; - var v = t.TokenSet.fromClause(c), - L = this.tokenSet.intersect(v).toArray(); - if (L.length === 0 && c.presence === t.Query.presence.REQUIRED) { - for (var f = 0; f < c.fields.length; f++) { - var y = c.fields[f]; - o[y] = t.Set.empty; - } - break; - } - for (var S = 0; S < L.length; S++) - for ( - var w = L[S], k = this.invertedIndex[w], _ = k._index, f = 0; - f < c.fields.length; - f++ - ) { - var y = c.fields[f], - q = k[y], - F = Object.keys(q), - j = w + "/" + y, - $ = new t.Set(F); - if ( - (c.presence == t.Query.presence.REQUIRED && - ((m = m.union($)), - o[y] === void 0 && (o[y] = t.Set.complete)), - c.presence == t.Query.presence.PROHIBITED) - ) { - (a[y] === void 0 && (a[y] = t.Set.empty), - (a[y] = a[y].union($))); - continue; - } - if ( - (i[y].upsert(_, c.boost, function (He, Ae) { - return He + Ae; - }), - !s[j]) - ) { - for (var N = 0; N < F.length; N++) { - var z = F[N], - Q = new t.FieldRef(z, y), - W = q[z], - U; - (U = r[Q]) === void 0 - ? (r[Q] = new t.MatchData(w, y, W)) - : U.add(w, y, W); - } - s[j] = !0; - } - } - } - if (c.presence === t.Query.presence.REQUIRED) - for (var f = 0; f < c.fields.length; f++) { - var y = c.fields[f]; - o[y] = o[y].intersect(m); - } - } - for ( - var V = t.Set.complete, M = t.Set.empty, l = 0; - l < this.fields.length; - l++ - ) { - var y = this.fields[l]; - (o[y] && (V = V.intersect(o[y])), a[y] && (M = M.union(a[y]))); - } - var u = Object.keys(r), - g = [], - P = Object.create(null); - if (n.isNegated()) { - u = Object.keys(this.fieldVectors); - for (var l = 0; l < u.length; l++) { - var Q = u[l], - T = t.FieldRef.fromString(Q); - r[Q] = new t.MatchData(); - } - } - for (var l = 0; l < u.length; l++) { - var T = t.FieldRef.fromString(u[l]), - h = T.docRef; - if (V.contains(h) && !M.contains(h)) { - var x = this.fieldVectors[T], - O = i[T.fieldName].similarity(x), - R; - if ((R = P[h]) !== void 0) - ((R.score += O), R.matchData.combine(r[T])); - else { - var E = { ref: h, score: O, matchData: r[T] }; - ((P[h] = E), g.push(E)); - } - } - } - return g.sort(function (Me, Re) { - return Re.score - Me.score; - }); - }), - (t.Index.prototype.toJSON = function () { - var e = Object.keys(this.invertedIndex) - .sort() - .map(function (r) { - return [r, this.invertedIndex[r]]; - }, this), - n = Object.keys(this.fieldVectors).map(function (r) { - return [r, this.fieldVectors[r].toJSON()]; - }, this); - return { - version: t.version, - fields: this.fields, - fieldVectors: n, - invertedIndex: e, - pipeline: this.pipeline.toJSON(), - }; - }), - (t.Index.load = function (e) { - var n = {}, - r = {}, - i = e.fieldVectors, - s = Object.create(null), - o = e.invertedIndex, - a = new t.TokenSet.Builder(), - l = t.Pipeline.load(e.pipeline); - e.version != t.version && - t.utils.warn( - "Version mismatch when loading serialised index. Current version of lunr '" + - t.version + - "' does not match serialized index '" + - e.version + - "'", - ); - for (var c = 0; c < i.length; c++) { - var d = i[c], - m = d[0], - p = d[1]; - r[m] = new t.Vector(p); - } - for (var c = 0; c < o.length; c++) { - var d = o[c], - b = d[0], - v = d[1]; - (a.insert(b), (s[b] = v)); - } - return ( - a.finish(), - (n.fields = e.fields), - (n.fieldVectors = r), - (n.invertedIndex = s), - (n.tokenSet = a.root), - (n.pipeline = l), - new t.Index(n) - ); - })); - ((t.Builder = function () { - ((this._ref = "id"), - (this._fields = Object.create(null)), - (this._documents = Object.create(null)), - (this.invertedIndex = Object.create(null)), - (this.fieldTermFrequencies = {}), - (this.fieldLengths = {}), - (this.tokenizer = t.tokenizer), - (this.pipeline = new t.Pipeline()), - (this.searchPipeline = new t.Pipeline()), - (this.documentCount = 0), - (this._b = 0.75), - (this._k1 = 1.2), - (this.termIndex = 0), - (this.metadataWhitelist = [])); - }), - (t.Builder.prototype.ref = function (e) { - this._ref = e; - }), - (t.Builder.prototype.field = function (e, n) { - if (/\//.test(e)) - throw new RangeError( - "Field '" + e + "' contains illegal character '/'", - ); - this._fields[e] = n || {}; - }), - (t.Builder.prototype.b = function (e) { - e < 0 ? (this._b = 0) : e > 1 ? (this._b = 1) : (this._b = e); - }), - (t.Builder.prototype.k1 = function (e) { - this._k1 = e; - }), - (t.Builder.prototype.add = function (e, n) { - var r = e[this._ref], - i = Object.keys(this._fields); - ((this._documents[r] = n || {}), (this.documentCount += 1)); - for (var s = 0; s < i.length; s++) { - var o = i[s], - a = this._fields[o].extractor, - l = a ? a(e) : e[o], - c = this.tokenizer(l, { fields: [o] }), - d = this.pipeline.run(c), - m = new t.FieldRef(r, o), - p = Object.create(null); - ((this.fieldTermFrequencies[m] = p), - (this.fieldLengths[m] = 0), - (this.fieldLengths[m] += d.length)); - for (var b = 0; b < d.length; b++) { - var v = d[b]; - if ( - (p[v] == null && (p[v] = 0), - (p[v] += 1), - this.invertedIndex[v] == null) - ) { - var L = Object.create(null); - ((L._index = this.termIndex), (this.termIndex += 1)); - for (var f = 0; f < i.length; f++) - L[i[f]] = Object.create(null); - this.invertedIndex[v] = L; - } - this.invertedIndex[v][o][r] == null && - (this.invertedIndex[v][o][r] = Object.create(null)); - for (var y = 0; y < this.metadataWhitelist.length; y++) { - var S = this.metadataWhitelist[y], - w = v.metadata[S]; - (this.invertedIndex[v][o][r][S] == null && - (this.invertedIndex[v][o][r][S] = []), - this.invertedIndex[v][o][r][S].push(w)); - } - } - } - }), - (t.Builder.prototype.calculateAverageFieldLengths = function () { - for ( - var e = Object.keys(this.fieldLengths), - n = e.length, - r = {}, - i = {}, - s = 0; - s < n; - s++ - ) { - var o = t.FieldRef.fromString(e[s]), - a = o.fieldName; - (i[a] || (i[a] = 0), - (i[a] += 1), - r[a] || (r[a] = 0), - (r[a] += this.fieldLengths[o])); - } - for (var l = Object.keys(this._fields), s = 0; s < l.length; s++) { - var c = l[s]; - r[c] = r[c] / i[c]; - } - this.averageFieldLength = r; - }), - (t.Builder.prototype.createFieldVectors = function () { - for ( - var e = {}, - n = Object.keys(this.fieldTermFrequencies), - r = n.length, - i = Object.create(null), - s = 0; - s < r; - s++ - ) { - for ( - var o = t.FieldRef.fromString(n[s]), - a = o.fieldName, - l = this.fieldLengths[o], - c = new t.Vector(), - d = this.fieldTermFrequencies[o], - m = Object.keys(d), - p = m.length, - b = this._fields[a].boost || 1, - v = this._documents[o.docRef].boost || 1, - L = 0; - L < p; - L++ - ) { - var f = m[L], - y = d[f], - S = this.invertedIndex[f]._index, - w, - k, - _; - (i[f] === void 0 - ? ((w = t.idf(this.invertedIndex[f], this.documentCount)), - (i[f] = w)) - : (w = i[f]), - (k = - (w * ((this._k1 + 1) * y)) / - (this._k1 * - (1 - this._b + this._b * (l / this.averageFieldLength[a])) + - y)), - (k *= b), - (k *= v), - (_ = Math.round(k * 1e3) / 1e3), - c.insert(S, _)); - } - e[o] = c; - } - this.fieldVectors = e; - }), - (t.Builder.prototype.createTokenSet = function () { - this.tokenSet = t.TokenSet.fromArray( - Object.keys(this.invertedIndex).sort(), - ); - }), - (t.Builder.prototype.build = function () { - return ( - this.calculateAverageFieldLengths(), - this.createFieldVectors(), - this.createTokenSet(), - new t.Index({ - invertedIndex: this.invertedIndex, - fieldVectors: this.fieldVectors, - tokenSet: this.tokenSet, - fields: Object.keys(this._fields), - pipeline: this.searchPipeline, - }) - ); - }), - (t.Builder.prototype.use = function (e) { - var n = Array.prototype.slice.call(arguments, 1); - (n.unshift(this), e.apply(this, n)); - }), - (t.MatchData = function (e, n, r) { - for ( - var i = Object.create(null), s = Object.keys(r || {}), o = 0; - o < s.length; - o++ - ) { - var a = s[o]; - i[a] = r[a].slice(); - } - ((this.metadata = Object.create(null)), - e !== void 0 && - ((this.metadata[e] = Object.create(null)), - (this.metadata[e][n] = i))); - }), - (t.MatchData.prototype.combine = function (e) { - for (var n = Object.keys(e.metadata), r = 0; r < n.length; r++) { - var i = n[r], - s = Object.keys(e.metadata[i]); - this.metadata[i] == null && - (this.metadata[i] = Object.create(null)); - for (var o = 0; o < s.length; o++) { - var a = s[o], - l = Object.keys(e.metadata[i][a]); - this.metadata[i][a] == null && - (this.metadata[i][a] = Object.create(null)); - for (var c = 0; c < l.length; c++) { - var d = l[c]; - this.metadata[i][a][d] == null - ? (this.metadata[i][a][d] = e.metadata[i][a][d]) - : (this.metadata[i][a][d] = this.metadata[i][a][d].concat( - e.metadata[i][a][d], - )); - } - } - } - }), - (t.MatchData.prototype.add = function (e, n, r) { - if (!(e in this.metadata)) { - ((this.metadata[e] = Object.create(null)), - (this.metadata[e][n] = r)); - return; - } - if (!(n in this.metadata[e])) { - this.metadata[e][n] = r; - return; - } - for (var i = Object.keys(r), s = 0; s < i.length; s++) { - var o = i[s]; - o in this.metadata[e][n] - ? (this.metadata[e][n][o] = this.metadata[e][n][o].concat(r[o])) - : (this.metadata[e][n][o] = r[o]); - } - }), - (t.Query = function (e) { - ((this.clauses = []), (this.allFields = e)); - }), - (t.Query.wildcard = new String("*")), - (t.Query.wildcard.NONE = 0), - (t.Query.wildcard.LEADING = 1), - (t.Query.wildcard.TRAILING = 2), - (t.Query.presence = { OPTIONAL: 1, REQUIRED: 2, PROHIBITED: 3 }), - (t.Query.prototype.clause = function (e) { - return ( - "fields" in e || (e.fields = this.allFields), - "boost" in e || (e.boost = 1), - "usePipeline" in e || (e.usePipeline = !0), - "wildcard" in e || (e.wildcard = t.Query.wildcard.NONE), - e.wildcard & t.Query.wildcard.LEADING && - e.term.charAt(0) != t.Query.wildcard && - (e.term = "*" + e.term), - e.wildcard & t.Query.wildcard.TRAILING && - e.term.slice(-1) != t.Query.wildcard && - (e.term = "" + e.term + "*"), - "presence" in e || (e.presence = t.Query.presence.OPTIONAL), - this.clauses.push(e), - this - ); - }), - (t.Query.prototype.isNegated = function () { - for (var e = 0; e < this.clauses.length; e++) - if (this.clauses[e].presence != t.Query.presence.PROHIBITED) - return !1; - return !0; - }), - (t.Query.prototype.term = function (e, n) { - if (Array.isArray(e)) - return ( - e.forEach(function (i) { - this.term(i, t.utils.clone(n)); - }, this), - this - ); - var r = n || {}; - return ((r.term = e.toString()), this.clause(r), this); - }), - (t.QueryParseError = function (e, n, r) { - ((this.name = "QueryParseError"), - (this.message = e), - (this.start = n), - (this.end = r)); - }), - (t.QueryParseError.prototype = new Error()), - (t.QueryLexer = function (e) { - ((this.lexemes = []), - (this.str = e), - (this.length = e.length), - (this.pos = 0), - (this.start = 0), - (this.escapeCharPositions = [])); - }), - (t.QueryLexer.prototype.run = function () { - for (var e = t.QueryLexer.lexText; e; ) e = e(this); - }), - (t.QueryLexer.prototype.sliceString = function () { - for ( - var e = [], n = this.start, r = this.pos, i = 0; - i < this.escapeCharPositions.length; - i++ - ) - ((r = this.escapeCharPositions[i]), - e.push(this.str.slice(n, r)), - (n = r + 1)); - return ( - e.push(this.str.slice(n, this.pos)), - (this.escapeCharPositions.length = 0), - e.join("") - ); - }), - (t.QueryLexer.prototype.emit = function (e) { - (this.lexemes.push({ - type: e, - str: this.sliceString(), - start: this.start, - end: this.pos, - }), - (this.start = this.pos)); - }), - (t.QueryLexer.prototype.escapeCharacter = function () { - (this.escapeCharPositions.push(this.pos - 1), (this.pos += 1)); - }), - (t.QueryLexer.prototype.next = function () { - if (this.pos >= this.length) return t.QueryLexer.EOS; - var e = this.str.charAt(this.pos); - return ((this.pos += 1), e); - }), - (t.QueryLexer.prototype.width = function () { - return this.pos - this.start; - }), - (t.QueryLexer.prototype.ignore = function () { - (this.start == this.pos && (this.pos += 1), (this.start = this.pos)); - }), - (t.QueryLexer.prototype.backup = function () { - this.pos -= 1; - }), - (t.QueryLexer.prototype.acceptDigitRun = function () { - var e, n; - do ((e = this.next()), (n = e.charCodeAt(0))); - while (n > 47 && n < 58); - e != t.QueryLexer.EOS && this.backup(); - }), - (t.QueryLexer.prototype.more = function () { - return this.pos < this.length; - }), - (t.QueryLexer.EOS = "EOS"), - (t.QueryLexer.FIELD = "FIELD"), - (t.QueryLexer.TERM = "TERM"), - (t.QueryLexer.EDIT_DISTANCE = "EDIT_DISTANCE"), - (t.QueryLexer.BOOST = "BOOST"), - (t.QueryLexer.PRESENCE = "PRESENCE"), - (t.QueryLexer.lexField = function (e) { - return ( - e.backup(), - e.emit(t.QueryLexer.FIELD), - e.ignore(), - t.QueryLexer.lexText - ); - }), - (t.QueryLexer.lexTerm = function (e) { - if ( - (e.width() > 1 && (e.backup(), e.emit(t.QueryLexer.TERM)), - e.ignore(), - e.more()) - ) - return t.QueryLexer.lexText; - }), - (t.QueryLexer.lexEditDistance = function (e) { - return ( - e.ignore(), - e.acceptDigitRun(), - e.emit(t.QueryLexer.EDIT_DISTANCE), - t.QueryLexer.lexText - ); - }), - (t.QueryLexer.lexBoost = function (e) { - return ( - e.ignore(), - e.acceptDigitRun(), - e.emit(t.QueryLexer.BOOST), - t.QueryLexer.lexText - ); - }), - (t.QueryLexer.lexEOS = function (e) { - e.width() > 0 && e.emit(t.QueryLexer.TERM); - }), - (t.QueryLexer.termSeparator = t.tokenizer.separator), - (t.QueryLexer.lexText = function (e) { - for (;;) { - var n = e.next(); - if (n == t.QueryLexer.EOS) return t.QueryLexer.lexEOS; - if (n.charCodeAt(0) == 92) { - e.escapeCharacter(); - continue; - } - if (n == ":") return t.QueryLexer.lexField; - if (n == "~") - return ( - e.backup(), - e.width() > 0 && e.emit(t.QueryLexer.TERM), - t.QueryLexer.lexEditDistance - ); - if (n == "^") - return ( - e.backup(), - e.width() > 0 && e.emit(t.QueryLexer.TERM), - t.QueryLexer.lexBoost - ); - if ((n == "+" && e.width() === 1) || (n == "-" && e.width() === 1)) - return (e.emit(t.QueryLexer.PRESENCE), t.QueryLexer.lexText); - if (n.match(t.QueryLexer.termSeparator)) - return t.QueryLexer.lexTerm; - } - }), - (t.QueryParser = function (e, n) { - ((this.lexer = new t.QueryLexer(e)), - (this.query = n), - (this.currentClause = {}), - (this.lexemeIdx = 0)); - }), - (t.QueryParser.prototype.parse = function () { - (this.lexer.run(), (this.lexemes = this.lexer.lexemes)); - for (var e = t.QueryParser.parseClause; e; ) e = e(this); - return this.query; - }), - (t.QueryParser.prototype.peekLexeme = function () { - return this.lexemes[this.lexemeIdx]; - }), - (t.QueryParser.prototype.consumeLexeme = function () { - var e = this.peekLexeme(); - return ((this.lexemeIdx += 1), e); - }), - (t.QueryParser.prototype.nextClause = function () { - var e = this.currentClause; - (this.query.clause(e), (this.currentClause = {})); - }), - (t.QueryParser.parseClause = function (e) { - var n = e.peekLexeme(); - if (n != null) - switch (n.type) { - case t.QueryLexer.PRESENCE: - return t.QueryParser.parsePresence; - case t.QueryLexer.FIELD: - return t.QueryParser.parseField; - case t.QueryLexer.TERM: - return t.QueryParser.parseTerm; - default: - var r = "expected either a field or a term, found " + n.type; - throw ( - n.str.length >= 1 && (r += " with value '" + n.str + "'"), - new t.QueryParseError(r, n.start, n.end) - ); - } - }), - (t.QueryParser.parsePresence = function (e) { - var n = e.consumeLexeme(); - if (n != null) { - switch (n.str) { - case "-": - e.currentClause.presence = t.Query.presence.PROHIBITED; - break; - case "+": - e.currentClause.presence = t.Query.presence.REQUIRED; - break; - default: - var r = "unrecognised presence operator'" + n.str + "'"; - throw new t.QueryParseError(r, n.start, n.end); - } - var i = e.peekLexeme(); - if (i == null) { - var r = "expecting term or field, found nothing"; - throw new t.QueryParseError(r, n.start, n.end); - } - switch (i.type) { - case t.QueryLexer.FIELD: - return t.QueryParser.parseField; - case t.QueryLexer.TERM: - return t.QueryParser.parseTerm; - default: - var r = "expecting term or field, found '" + i.type + "'"; - throw new t.QueryParseError(r, i.start, i.end); - } - } - }), - (t.QueryParser.parseField = function (e) { - var n = e.consumeLexeme(); - if (n != null) { - if (e.query.allFields.indexOf(n.str) == -1) { - var r = e.query.allFields - .map(function (o) { - return "'" + o + "'"; - }) - .join(", "), - i = "unrecognised field '" + n.str + "', possible fields: " + r; - throw new t.QueryParseError(i, n.start, n.end); - } - e.currentClause.fields = [n.str]; - var s = e.peekLexeme(); - if (s == null) { - var i = "expecting term, found nothing"; - throw new t.QueryParseError(i, n.start, n.end); - } - switch (s.type) { - case t.QueryLexer.TERM: - return t.QueryParser.parseTerm; - default: - var i = "expecting term, found '" + s.type + "'"; - throw new t.QueryParseError(i, s.start, s.end); - } - } - }), - (t.QueryParser.parseTerm = function (e) { - var n = e.consumeLexeme(); - if (n != null) { - ((e.currentClause.term = n.str.toLowerCase()), - n.str.indexOf("*") != -1 && (e.currentClause.usePipeline = !1)); - var r = e.peekLexeme(); - if (r == null) { - e.nextClause(); - return; - } - switch (r.type) { - case t.QueryLexer.TERM: - return (e.nextClause(), t.QueryParser.parseTerm); - case t.QueryLexer.FIELD: - return (e.nextClause(), t.QueryParser.parseField); - case t.QueryLexer.EDIT_DISTANCE: - return t.QueryParser.parseEditDistance; - case t.QueryLexer.BOOST: - return t.QueryParser.parseBoost; - case t.QueryLexer.PRESENCE: - return (e.nextClause(), t.QueryParser.parsePresence); - default: - var i = "Unexpected lexeme type '" + r.type + "'"; - throw new t.QueryParseError(i, r.start, r.end); - } - } - }), - (t.QueryParser.parseEditDistance = function (e) { - var n = e.consumeLexeme(); - if (n != null) { - var r = parseInt(n.str, 10); - if (isNaN(r)) { - var i = "edit distance must be numeric"; - throw new t.QueryParseError(i, n.start, n.end); - } - e.currentClause.editDistance = r; - var s = e.peekLexeme(); - if (s == null) { - e.nextClause(); - return; - } - switch (s.type) { - case t.QueryLexer.TERM: - return (e.nextClause(), t.QueryParser.parseTerm); - case t.QueryLexer.FIELD: - return (e.nextClause(), t.QueryParser.parseField); - case t.QueryLexer.EDIT_DISTANCE: - return t.QueryParser.parseEditDistance; - case t.QueryLexer.BOOST: - return t.QueryParser.parseBoost; - case t.QueryLexer.PRESENCE: - return (e.nextClause(), t.QueryParser.parsePresence); - default: - var i = "Unexpected lexeme type '" + s.type + "'"; - throw new t.QueryParseError(i, s.start, s.end); - } - } - }), - (t.QueryParser.parseBoost = function (e) { - var n = e.consumeLexeme(); - if (n != null) { - var r = parseInt(n.str, 10); - if (isNaN(r)) { - var i = "boost must be numeric"; - throw new t.QueryParseError(i, n.start, n.end); - } - e.currentClause.boost = r; - var s = e.peekLexeme(); - if (s == null) { - e.nextClause(); - return; - } - switch (s.type) { - case t.QueryLexer.TERM: - return (e.nextClause(), t.QueryParser.parseTerm); - case t.QueryLexer.FIELD: - return (e.nextClause(), t.QueryParser.parseField); - case t.QueryLexer.EDIT_DISTANCE: - return t.QueryParser.parseEditDistance; - case t.QueryLexer.BOOST: - return t.QueryParser.parseBoost; - case t.QueryLexer.PRESENCE: - return (e.nextClause(), t.QueryParser.parsePresence); - default: - var i = "Unexpected lexeme type '" + s.type + "'"; - throw new t.QueryParseError(i, s.start, s.end); - } - } - }), - (function (e, n) { - typeof define == "function" && define.amd - ? define(n) - : typeof de == "object" - ? (he.exports = n()) - : (e.lunr = n()); - })(this, function () { - return t; - })); - })(); - }); - window.translations ||= { - copy: "Copy", - copied: "Copied!", - normally_hidden: - "This member is normally hidden due to your filter settings.", - hierarchy_expand: "Expand", - hierarchy_collapse: "Collapse", - }; - var ce = []; - function G(t, e) { - ce.push({ selector: e, constructor: t }); - } - var J = class { - alwaysVisibleMember = null; - constructor() { - (this.createComponents(document.body), - this.ensureFocusedElementVisible(), - this.listenForCodeCopies(), - window.addEventListener("hashchange", () => - this.ensureFocusedElementVisible(), - ), - document.body.style.display || - (this.ensureFocusedElementVisible(), - this.updateIndexVisibility(), - this.scrollToHash())); - } - createComponents(e) { - ce.forEach((n) => { - e.querySelectorAll(n.selector).forEach((r) => { - r.dataset.hasInstance || - (new n.constructor({ el: r, app: this }), - (r.dataset.hasInstance = String(!0))); - }); - }); - } - filterChanged() { - this.ensureFocusedElementVisible(); - } - showPage() { - document.body.style.display && - (document.body.style.removeProperty("display"), - this.ensureFocusedElementVisible(), - this.updateIndexVisibility(), - this.scrollToHash()); - } - scrollToHash() { - if (location.hash) { - let e = document.getElementById(location.hash.substring(1)); - if (!e) return; - e.scrollIntoView({ behavior: "instant", block: "start" }); - } - } - ensureActivePageVisible() { - let e = document.querySelector(".tsd-navigation .current"), - n = e?.parentElement; - for (; n && !n.classList.contains(".tsd-navigation"); ) - (n instanceof HTMLDetailsElement && (n.open = !0), - (n = n.parentElement)); - if (e && !ze(e)) { - let r = - e.getBoundingClientRect().top - - document.documentElement.clientHeight / 4; - ((document.querySelector(".site-menu").scrollTop = r), - (document.querySelector(".col-sidebar").scrollTop = r)); - } - } - updateIndexVisibility() { - let e = document.querySelector(".tsd-index-content"), - n = e?.open; - (e && (e.open = !0), - document.querySelectorAll(".tsd-index-section").forEach((r) => { - r.style.display = "block"; - let i = Array.from(r.querySelectorAll(".tsd-index-link")).every( - (s) => s.offsetParent == null, - ); - r.style.display = i ? "none" : "block"; - }), - e && (e.open = n)); - } - ensureFocusedElementVisible() { - if ( - (this.alwaysVisibleMember && - (this.alwaysVisibleMember.classList.remove("always-visible"), - this.alwaysVisibleMember.firstElementChild.remove(), - (this.alwaysVisibleMember = null)), - !location.hash) - ) - return; - let e = document.getElementById(location.hash.substring(1)); - if (!e) return; - let n = e.parentElement; - for (; n && n.tagName !== "SECTION"; ) n = n.parentElement; - if (!n) return; - let r = n.offsetParent == null, - i = n; - for (; i !== document.body; ) - (i instanceof HTMLDetailsElement && (i.open = !0), - (i = i.parentElement)); - if (n.offsetParent == null) { - ((this.alwaysVisibleMember = n), n.classList.add("always-visible")); - let s = document.createElement("p"); - (s.classList.add("warning"), - (s.textContent = window.translations.normally_hidden), - n.prepend(s)); - } - r && e.scrollIntoView(); - } - listenForCodeCopies() { - document.querySelectorAll("pre > button").forEach((e) => { - let n; - e.addEventListener("click", () => { - (e.previousElementSibling instanceof HTMLElement && - navigator.clipboard.writeText( - e.previousElementSibling.innerText.trim(), - ), - (e.textContent = window.translations.copied), - e.classList.add("visible"), - clearTimeout(n), - (n = setTimeout(() => { - (e.classList.remove("visible"), - (n = setTimeout(() => { - e.textContent = window.translations.copy; - }, 100))); - }, 1e3))); - }); - }); - } - }; - function ze(t) { - let e = t.getBoundingClientRect(), - n = Math.max(document.documentElement.clientHeight, window.innerHeight); - return !(e.bottom < 0 || e.top - n >= 0); - } - var ue = (t, e = 100) => { - let n; - return () => { - (clearTimeout(n), (n = setTimeout(() => t(), e))); - }; - }; - var ge = $e(pe(), 1); - async function H(t) { - let e = Uint8Array.from(atob(t), (s) => s.charCodeAt(0)), - r = new Blob([e]) - .stream() - .pipeThrough(new DecompressionStream("deflate")), - i = await new Response(r).text(); - return JSON.parse(i); - } - async function fe(t, e) { - if (!window.searchData) return; - let n = await H(window.searchData); - ((t.data = n), - (t.index = ge.Index.load(n.index)), - e.classList.remove("loading"), - e.classList.add("ready")); - } - function ve() { - let t = document.getElementById("tsd-search"); - if (!t) return; - let e = { base: document.documentElement.dataset.base + "/" }, - n = document.getElementById("tsd-search-script"); - (t.classList.add("loading"), - n && - (n.addEventListener("error", () => { - (t.classList.remove("loading"), t.classList.add("failure")); - }), - n.addEventListener("load", () => { - fe(e, t); - }), - fe(e, t))); - let r = document.querySelector("#tsd-search input"), - i = document.querySelector("#tsd-search .results"); - if (!r || !i) - throw new Error( - "The input field or the result list wrapper was not found", - ); - (i.addEventListener("mouseup", () => { - re(t); - }), - r.addEventListener("focus", () => t.classList.add("has-focus")), - We(t, i, r, e)); - } - function We(t, e, n, r) { - (n.addEventListener( - "input", - ue(() => { - Ue(t, e, n, r); - }, 200), - ), - n.addEventListener("keydown", (i) => { - i.key == "Enter" - ? Je(e, t) - : i.key == "ArrowUp" - ? (me(e, n, -1), i.preventDefault()) - : i.key === "ArrowDown" && (me(e, n, 1), i.preventDefault()); - }), - document.body.addEventListener("keypress", (i) => { - i.altKey || - i.ctrlKey || - i.metaKey || - (!n.matches(":focus") && - i.key === "/" && - (i.preventDefault(), n.focus())); - }), - document.body.addEventListener("keyup", (i) => { - t.classList.contains("has-focus") && - (i.key === "Escape" || - (!e.matches(":focus-within") && !n.matches(":focus"))) && - (n.blur(), re(t)); - })); - } - function re(t) { - t.classList.remove("has-focus"); - } - function Ue(t, e, n, r) { - if (!r.index || !r.data) return; - e.textContent = ""; - let i = n.value.trim(), - s; - if (i) { - let o = i - .split(" ") - .map((a) => (a.length ? `*${a}*` : "")) - .join(" "); - s = r.index.search(o); - } else s = []; - for (let o = 0; o < s.length; o++) { - let a = s[o], - l = r.data.rows[Number(a.ref)], - c = 1; - (l.name.toLowerCase().startsWith(i.toLowerCase()) && - (c *= 1 + 1 / (1 + Math.abs(l.name.length - i.length))), - (a.score *= c)); - } - if (s.length === 0) { - let o = document.createElement("li"); - o.classList.add("no-results"); - let a = document.createElement("span"); - ((a.textContent = "No results found"), - o.appendChild(a), - e.appendChild(o)); - } - s.sort((o, a) => a.score - o.score); - for (let o = 0, a = Math.min(10, s.length); o < a; o++) { - let l = r.data.rows[Number(s[o].ref)], - c = ``, - d = ye(l.name, i); - (globalThis.DEBUG_SEARCH_WEIGHTS && - (d += ` (score: ${s[o].score.toFixed(2)})`), - l.parent && - (d = ` - ${ye(l.parent, i)}.${d}`)); - let m = document.createElement("li"); - m.classList.value = l.classes ?? ""; - let p = document.createElement("a"); - ((p.href = r.base + l.url), - (p.innerHTML = c + d), - m.append(p), - p.addEventListener("focus", () => { - (e.querySelector(".current")?.classList.remove("current"), - m.classList.add("current")); - }), - e.appendChild(m)); - } - } - function me(t, e, n) { - let r = t.querySelector(".current"); - if (!r) - ((r = t.querySelector(n == 1 ? "li:first-child" : "li:last-child")), - r && r.classList.add("current")); - else { - let i = r; - if (n === 1) - do i = i.nextElementSibling ?? void 0; - while (i instanceof HTMLElement && i.offsetParent == null); - else - do i = i.previousElementSibling ?? void 0; - while (i instanceof HTMLElement && i.offsetParent == null); - i - ? (r.classList.remove("current"), i.classList.add("current")) - : n === -1 && (r.classList.remove("current"), e.focus()); - } - } - function Je(t, e) { - let n = t.querySelector(".current"); - if ((n || (n = t.querySelector("li:first-child")), n)) { - let r = n.querySelector("a"); - (r && (window.location.href = r.href), re(e)); - } - } - function ye(t, e) { - if (e === "") return t; - let n = t.toLocaleLowerCase(), - r = e.toLocaleLowerCase(), - i = [], - s = 0, - o = n.indexOf(r); - for (; o != -1; ) - (i.push( - ne(t.substring(s, o)), - `${ne(t.substring(o, o + r.length))}`, - ), - (s = o + r.length), - (o = n.indexOf(r, s))); - return (i.push(ne(t.substring(s))), i.join("")); - } - var Ge = { - "&": "&", - "<": "<", - ">": ">", - "'": "'", - '"': """, - }; - function ne(t) { - return t.replace(/[&<>"'"]/g, (e) => Ge[e]); - } - var I = class { - el; - app; - constructor(e) { - ((this.el = e.el), (this.app = e.app)); - } - }; - var A = "mousedown", - Ee = "mousemove", - B = "mouseup", - X = { x: 0, y: 0 }, - xe = !1, - ie = !1, - Xe = !1, - D = !1, - Le = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( - navigator.userAgent, - ); - document.documentElement.classList.add(Le ? "is-mobile" : "not-mobile"); - Le && - "ontouchstart" in document.documentElement && - ((Xe = !0), (A = "touchstart"), (Ee = "touchmove"), (B = "touchend")); - document.addEventListener(A, (t) => { - ((ie = !0), (D = !1)); - let e = A == "touchstart" ? t.targetTouches[0] : t; - ((X.y = e.pageY || 0), (X.x = e.pageX || 0)); - }); - document.addEventListener(Ee, (t) => { - if (ie && !D) { - let e = A == "touchstart" ? t.targetTouches[0] : t, - n = X.x - (e.pageX || 0), - r = X.y - (e.pageY || 0); - D = Math.sqrt(n * n + r * r) > 10; - } - }); - document.addEventListener(B, () => { - ie = !1; - }); - document.addEventListener("click", (t) => { - xe && (t.preventDefault(), t.stopImmediatePropagation(), (xe = !1)); - }); - var Y = class extends I { - active; - className; - constructor(e) { - (super(e), - (this.className = this.el.dataset.toggle || ""), - this.el.addEventListener(B, (n) => this.onPointerUp(n)), - this.el.addEventListener("click", (n) => n.preventDefault()), - document.addEventListener(A, (n) => this.onDocumentPointerDown(n)), - document.addEventListener(B, (n) => this.onDocumentPointerUp(n))); - } - setActive(e) { - if (this.active == e) return; - ((this.active = e), - document.documentElement.classList.toggle("has-" + this.className, e), - this.el.classList.toggle("active", e)); - let n = (this.active ? "to-has-" : "from-has-") + this.className; - (document.documentElement.classList.add(n), - setTimeout(() => document.documentElement.classList.remove(n), 500)); - } - onPointerUp(e) { - D || (this.setActive(!0), e.preventDefault()); - } - onDocumentPointerDown(e) { - if (this.active) { - if (e.target.closest(".col-sidebar, .tsd-filter-group")) return; - this.setActive(!1); - } - } - onDocumentPointerUp(e) { - if (!D && this.active && e.target.closest(".col-sidebar")) { - let n = e.target.closest("a"); - if (n) { - let r = window.location.href; - (r.indexOf("#") != -1 && (r = r.substring(0, r.indexOf("#"))), - n.href.substring(0, r.length) == r && - setTimeout(() => this.setActive(!1), 250)); - } - } - } - }; - var se; - try { - se = localStorage; - } catch { - se = { - getItem() { - return null; - }, - setItem() {}, - }; - } - var C = se; - var be = document.head.appendChild(document.createElement("style")); - be.dataset.for = "filters"; - var Z = class extends I { - key; - value; - constructor(e) { - (super(e), - (this.key = `filter-${this.el.name}`), - (this.value = this.el.checked), - this.el.addEventListener("change", () => { - this.setLocalStorage(this.el.checked); - }), - this.setLocalStorage(this.fromLocalStorage()), - (be.innerHTML += `html:not(.${this.key}) .tsd-is-${this.el.name} { display: none; } -`), - this.app.updateIndexVisibility()); - } - fromLocalStorage() { - let e = C.getItem(this.key); - return e ? e === "true" : this.el.checked; - } - setLocalStorage(e) { - (C.setItem(this.key, e.toString()), - (this.value = e), - this.handleValueChange()); - } - handleValueChange() { - ((this.el.checked = this.value), - document.documentElement.classList.toggle(this.key, this.value), - this.app.filterChanged(), - this.app.updateIndexVisibility()); - } - }; - var oe = new Map(), - ae = class { - open; - accordions = []; - key; - constructor(e, n) { - ((this.key = e), (this.open = n)); - } - add(e) { - (this.accordions.push(e), - (e.open = this.open), - e.addEventListener("toggle", () => { - this.toggle(e.open); - })); - } - toggle(e) { - for (let n of this.accordions) n.open = e; - C.setItem(this.key, e.toString()); - } - }, - K = class extends I { - constructor(e) { - super(e); - let n = this.el.querySelector("summary"), - r = n.querySelector("a"); - r && - r.addEventListener("click", () => { - location.assign(r.href); - }); - let i = `tsd-accordion-${n.dataset.key ?? n.textContent.trim().replace(/\s+/g, "-").toLowerCase()}`, - s; - if (oe.has(i)) s = oe.get(i); - else { - let o = C.getItem(i), - a = o ? o === "true" : this.el.open; - ((s = new ae(i, a)), oe.set(i, s)); - } - s.add(this.el); - } - }; - function Se(t) { - let e = C.getItem("tsd-theme") || "os"; - ((t.value = e), - we(e), - t.addEventListener("change", () => { - (C.setItem("tsd-theme", t.value), we(t.value)); - })); - } - function we(t) { - document.documentElement.dataset.theme = t; - } - var ee; - function Ce() { - let t = document.getElementById("tsd-nav-script"); - t && (t.addEventListener("load", Te), Te()); - } - async function Te() { - let t = document.getElementById("tsd-nav-container"); - if (!t || !window.navigationData) return; - let e = await H(window.navigationData); - ((ee = document.documentElement.dataset.base), - ee.endsWith("/") || (ee += "/"), - (t.innerHTML = "")); - for (let n of e) Ie(n, t, []); - (window.app.createComponents(t), - window.app.showPage(), - window.app.ensureActivePageVisible()); - } - function Ie(t, e, n) { - let r = e.appendChild(document.createElement("li")); - if (t.children) { - let i = [...n, t.text], - s = r.appendChild(document.createElement("details")); - s.className = t.class ? `${t.class} tsd-accordion` : "tsd-accordion"; - let o = s.appendChild(document.createElement("summary")); - ((o.className = "tsd-accordion-summary"), - (o.dataset.key = i.join("$")), - (o.innerHTML = - ''), - ke(t, o)); - let a = s.appendChild(document.createElement("div")); - a.className = "tsd-accordion-details"; - let l = a.appendChild(document.createElement("ul")); - l.className = "tsd-nested-navigation"; - for (let c of t.children) Ie(c, l, i); - } else ke(t, r, t.class); - } - function ke(t, e, n) { - if (t.path) { - let r = e.appendChild(document.createElement("a")); - ((r.href = ee + t.path), - n && (r.className = n), - location.pathname === r.pathname && - !r.href.includes("#") && - r.classList.add("current"), - t.kind && - (r.innerHTML = ``), - (r.appendChild(document.createElement("span")).textContent = t.text)); - } else { - let r = e.appendChild(document.createElement("span")); - ((r.innerHTML = - ''), - (r.appendChild(document.createElement("span")).textContent = t.text)); - } - } - var te = document.documentElement.dataset.base; - te.endsWith("/") || (te += "/"); - function Pe() { - document.querySelector(".tsd-full-hierarchy") - ? Ye() - : document.querySelector(".tsd-hierarchy") && Ze(); - } - function Ye() { - document.addEventListener("click", (r) => { - let i = r.target; - for (; i.parentElement && i.parentElement.tagName != "LI"; ) - i = i.parentElement; - i.dataset.dropdown && - (i.dataset.dropdown = String(i.dataset.dropdown !== "true")); - }); - let t = new Map(), - e = new Set(); - for (let r of document.querySelectorAll( - ".tsd-full-hierarchy [data-refl]", - )) { - let i = r.querySelector("ul"); - t.has(r.dataset.refl) - ? e.add(r.dataset.refl) - : i && t.set(r.dataset.refl, i); - } - for (let r of e) n(r); - function n(r) { - let i = t.get(r).cloneNode(!0); - (i.querySelectorAll("[id]").forEach((s) => { - s.removeAttribute("id"); - }), - i.querySelectorAll("[data-dropdown]").forEach((s) => { - s.dataset.dropdown = "false"; - })); - for (let s of document.querySelectorAll(`[data-refl="${r}"]`)) { - let o = tt(), - a = s.querySelector("ul"); - (s.insertBefore(o, a), - (o.dataset.dropdown = String(!!a)), - a || s.appendChild(i.cloneNode(!0))); - } - } - } - function Ze() { - let t = document.getElementById("tsd-hierarchy-script"); - t && (t.addEventListener("load", Qe), Qe()); - } - async function Qe() { - let t = document.querySelector(".tsd-panel.tsd-hierarchy:has(h4 a)"); - if (!t || !window.hierarchyData) return; - let e = +t.dataset.refl, - n = await H(window.hierarchyData), - r = t.querySelector("ul"), - i = document.createElement("ul"); - if ( - (i.classList.add("tsd-hierarchy"), - Ke(i, n, e), - r.querySelectorAll("li").length == i.querySelectorAll("li").length) - ) - return; - let s = document.createElement("span"); - (s.classList.add("tsd-hierarchy-toggle"), - (s.textContent = window.translations.hierarchy_expand), - t.querySelector("h4 a")?.insertAdjacentElement("afterend", s), - s.insertAdjacentText("beforebegin", ", "), - s.addEventListener("click", () => { - s.textContent === window.translations.hierarchy_expand - ? (r.insertAdjacentElement("afterend", i), - r.remove(), - (s.textContent = window.translations.hierarchy_collapse)) - : (i.insertAdjacentElement("afterend", r), - i.remove(), - (s.textContent = window.translations.hierarchy_expand)); - })); - } - function Ke(t, e, n) { - let r = e.roots.filter((i) => et(e, i, n)); - for (let i of r) t.appendChild(Oe(e, i, n)); - } - function Oe(t, e, n, r = new Set()) { - if (r.has(e)) return; - r.add(e); - let i = t.reflections[e], - s = document.createElement("li"); - if ((s.classList.add("tsd-hierarchy-item"), e === n)) { - let o = s.appendChild(document.createElement("span")); - ((o.textContent = i.name), o.classList.add("tsd-hierarchy-target")); - } else { - for (let a of i.uniqueNameParents || []) { - let l = t.reflections[a], - c = s.appendChild(document.createElement("a")); - ((c.textContent = l.name), - (c.href = te + l.url), - (c.className = l.class + " tsd-signature-type"), - s.append(document.createTextNode("."))); - } - let o = s.appendChild(document.createElement("a")); - ((o.textContent = t.reflections[e].name), - (o.href = te + i.url), - (o.className = i.class + " tsd-signature-type")); - } - if (i.children) { - let o = s.appendChild(document.createElement("ul")); - o.classList.add("tsd-hierarchy"); - for (let a of i.children) { - let l = Oe(t, a, n, r); - l && o.appendChild(l); - } - } - return (r.delete(e), s); - } - function et(t, e, n) { - if (e === n) return !0; - let r = new Set(), - i = [t.reflections[e]]; - for (; i.length; ) { - let s = i.pop(); - if (!r.has(s)) { - r.add(s); - for (let o of s.children || []) { - if (o === n) return !0; - i.push(t.reflections[o]); - } - } - } - return !1; - } - function tt() { - let t = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - return ( - t.setAttribute("width", "20"), - t.setAttribute("height", "20"), - t.setAttribute("viewBox", "0 0 24 24"), - t.setAttribute("fill", "none"), - (t.innerHTML = ''), - t - ); - } - G(Y, "a[data-toggle]"); - G(K, ".tsd-accordion"); - G(Z, ".tsd-filter-item input[type=checkbox]"); - var _e = document.getElementById("tsd-theme"); - _e && Se(_e); - var nt = new J(); - Object.defineProperty(window, "app", { value: nt }); - ve(); - Ce(); - Pe(); -})(); -/*! Bundled license information: - -lunr/lunr.js: - (** - * lunr - http://lunrjs.com - A bit like Solr, but much smaller and not as bright - 2.3.9 - * Copyright (C) 2020 Oliver Nightingale - * @license MIT - *) - (*! - * lunr.utils - * Copyright (C) 2020 Oliver Nightingale - *) - (*! - * lunr.Set - * Copyright (C) 2020 Oliver Nightingale - *) - (*! - * lunr.tokenizer - * Copyright (C) 2020 Oliver Nightingale - *) - (*! - * lunr.Pipeline - * Copyright (C) 2020 Oliver Nightingale - *) - (*! - * lunr.Vector - * Copyright (C) 2020 Oliver Nightingale - *) - (*! - * lunr.stemmer - * Copyright (C) 2020 Oliver Nightingale - * Includes code from - http://tartarus.org/~martin/PorterStemmer/js.txt - *) - (*! - * lunr.stopWordFilter - * Copyright (C) 2020 Oliver Nightingale - *) - (*! - * lunr.trimmer - * Copyright (C) 2020 Oliver Nightingale - *) - (*! - * lunr.TokenSet - * Copyright (C) 2020 Oliver Nightingale - *) - (*! - * lunr.Index - * Copyright (C) 2020 Oliver Nightingale - *) - (*! - * lunr.Builder - * Copyright (C) 2020 Oliver Nightingale - *) -*/ diff --git a/packages/binden/docs/assets/navigation.js b/packages/binden/docs/assets/navigation.js deleted file mode 100644 index 85e45b7a..00000000 --- a/packages/binden/docs/assets/navigation.js +++ /dev/null @@ -1,2 +0,0 @@ -window.navigationData = - "eJyVlU1TFDEQhv9LzlsiawG6N0EoOVBSi3qxKComPWzc2WRMMgJa/ndrdgc2H53OeO233yc9k+7Otz/Mw6NnC/ZeCOj8uRZGKn3PZqzjfsUWTLTcOXAHsf5q5Tctm7G10pItDudv/872pN6vjFW/uVdGI6BQpjinSktAALt43XlurbEl+1asM5bwswfnS5RRnsJxndEOyqCdTpHOjPagiStKEiawllzfIzWF6gTK56euDBnEKuMR+cOjQHvNWqFHD3HKeWHsA7cSZG5+kSj/ZXNlpGoUyBulBVJCkkCxrpSULTxwi2D2GkUoXGP1/pam94CMyC5Ofn9pXSjtwTZcDL+AWhnzo+MIh++MiFbeGwnslDvI2ipEBQk0aL8qPnXDsa7Ay/ImYCcQp8CKWyHEkZsBByY9hdCQ9sJR8X5ASPmOyEDxoMeMfNgTOzLtIaEw8QmkOPIhihz7BLif7Wtu+abQCWkWiSQurX5bu6kn2zJKoQc6mv0A55+6fDekrNfvTg6P5umCAO2V2I5/3FHPyCylSi3XN72yi16L4UBsjY+wPKVK/Qhcgs3rGuNT/V952+f1hGKVRHzXf3zPtTXeCNNmjGehSrjxXKwz+zY6zXvpYYP7B6XK+KKVz+xDsOYU/q4xNjj6F7eKf2/BHYxSTHiTmEcRMw8Sbf7hwvc0Mg8SbY5f0Micv52R+QM0vG/99kk8MxIwSpqT4W7/Ad2RP3I="; diff --git a/packages/binden/docs/assets/search.js b/packages/binden/docs/assets/search.js deleted file mode 100644 index 79d94608..00000000 --- a/packages/binden/docs/assets/search.js +++ /dev/null @@ -1,2 +0,0 @@ -window.searchData = - "eJytXduS2ziS/RfVq9rNBO9+252dju2IndiJ7pl5qXBU0BLLxe0qqZqi3O5x+N83CJJSInFAJlV+qguBTFxOHgB5KOjrpj3+cdq8v/+6+a057DfvTVTmlJrt5lC91Jv3m5//Y7erX7u/HnbHfXP4dNpsN+f2efN+0/35Wp9+lI/fPXUvz5vtZvdcnU71afN+s/m2vdhOs5Ddi9nm0NXtY7Xzbc+apsgkF9v1SqN3rAKzvt28Vm196EBrA45/f/hcPZ9rtd9r+dVuTXHxGhjJ0eSPK0bRRMnV7GN7fPm1a5Um7/rip6k47k6wNyldAbc7Hk5de951x1bl1y2/0rHJDCUzwJlzvAgarVeJmjmnS4gJ++RT2x1XTGx3vHVafS45d0/1oWt2VdccD//487X26MQrsYJRzt3TsW3+baviGOQl9HzS8YbO27sby4bi2WliwN2urff9GFTPJ61Xt8pa55xL4Bhe4KEePzWT+H3REEmgJ47X5vSv6rnZOyibcducPvfFZ+cv5FfNX2DmNPQV6q7LI52ypzd1UfhCGJ3rpQKfqkkNMxfo5yJxBT0K3grufm7Y9/zleOjqw/zGR5T5TjsfZFWx9ZENxsQR6tY0P2u6pCUP2CEFfYR7pA1k6FkTymHX2q3IbbOo9hvajEC3aDfSnfY/NKcfmsNT3TZdvVe2QhXksBEwzG9sBQjUX6rDJ7z68wL6ED01/1aauxuLzsfl0L6Qs65qO623saxuEFXu68Ne6Xwo+UbXPh25kydgtDxxK4mI9UfPQrIXKymI+VzBP9KpIAEHo2GHs/gMeVoT6MzX4mKu69n50HQKb2OxVXyyNKZOKM4M6qow1PnmcRj2vCYGQ34Bgf4jdHpiz7/DWUxaWziJ8caFzmFPVXuqZ+nz6u1a+HaHH4/nw75q/9R5ZKXXufRpEp6RtNOzkiSvHdBzpOjBSopkk6RnSOFSc9Rah0CVHwnBcM8W4Kfy5uEv6G4RewF/a8ifjaOS+6U3l4yOvzUh5ugf6SnotzoUoRc7d0OZYFzatgTMh1PI3MFC4njeRf3ltWlrnF3iTq7lbnLzUn15qIKb56uba7mb3OyPL1WDE37cy6XYTU5eq+5p0cVY6CYHp3p3bpfH6lLsJidPXff6cDw8LwOYl7ytP9VL/XBqOkWXWEm1K2cRc0L7SiOLYa1fuq6tVa1aorX6BevqR7dWCUeC0DlTIR9zLLVkO5Qm0FLUkn3JUcjDEj8t+ZAEhXwskdOSD8FOyMUCMy15cKgJ2Z+lpSXrgpeQ/QVOWvLgkxJyskxIiz3xGAl2ZpGNfD/Kbc3ViWJHI33wzcxPx/aPqt3XOLtyearf0jwyMgqbunuc4SLWqNDxBq86wsfHmRVn0cXT8YTPa8LJWO5WN6/tsTtq/EwFb3V0qndt4AQqPF1KrnHFFlEfUhNudXDSLqUSUcurKWq5dkEV3jRrKnIniORx2dNcpCg8fPS5UB8mCvtOnAQ8zMaIwofAbsDLAm4VftxgDLiZD0ToRUXqws0ir0NPnNp/fvzbcd88NvX+1+aww5tnUUZP8/sqsB1HFu/G0gFCkQ3FtBLqzjR+a7qipRjYGQXRhHukpRvoWUM6YddyN1mB7csNsxf2p4I99LgI/hmvPATCittaqS2ofs3m250m36RvzSTVZ417Lx78k8sV4zsH/f/mx4DFHxbAvp/ytU7yulXrWily3aIBzYk/K2xjlUcNtxt0HCXWbtPh1ghws1rQf1an2mZpv+CAZM/18d3Wv5/rwIZfGry7Fg4EI29i0OHp9Xg4YXICHi+l17mUAs2XgACwMFarZJKp0WqJxGmywOnzMZDVn7wMBdTWOVZPdfffdbWvF/pxqrunqdhtfrqqO6P0E3cylblxnCSAoZMl4Cq8CNQG3CygddFPExCZJw9NmJ0Wbe+Ph4X2jyVutN8bnjU/FLgRsSH5/QrWGeZesP5/J/CirmN9LHGT9UXyuRtL3GR9ZK8Z6z6/6a0/HtuXeetjiZtn9afmeQGVfanHodRt4//UHv9YmICxiNq+sy43h319+GvbHtv/fe1fg8WioF9Mv0o7AFUYnccranFIfqxPp5D8GPJ9rfNm9/WX12NoqxDwfqlyk3O2bWAFPfCwZ99l+yDtqbYQvIFzEtARLFuew4VhU/kKLPeer4UlX+ULUrbnaTYMAn7k+fHXrtr99nNXX3lwPEReHiycApE9aGvWTnw18V/1Y3V+7mzL/3LcX2f3c9U21cfn+vSjLKJ/t34YlWUiW81h1bk7PvT7SaXRO15hNpiXSKQfhIen6rB/rrFEA5zLSmsb4BFJAKzfkT7WMsdsIDOIIi9TCa19vh7v2rrq6l/r9jM4gDhdsQVPU8G3+OrF1RUe++Jv8nsOcu64FV7k2pDl4+PjrOXhudqyIKefzoddj+W/Nfv9c/1HxVTrkan8Enpuudb5e9VWL5heZKEVqfnm1LMfJhho9o5VCUS41+aA8+bT4djWD5Y3VnTsTta7oRmMa8C8TSBRTpiWc4Q5Fe+w1mmnMORuceKWfeEZCznUzRP06sRve/aXAulrKLPKg4jjcPxq41Za/Huv/u2Oz9Le9H91On0goV8CaRvn6XwL3TWrsp+gfgh+5ss3fOdXmaPNqcXhFsx+7hf5FxXe5P3jce8L3sDpWO5NvnbDu8KrBhvU+S5tgK+Rz/iffaFc7bt/syh00hFup6Jv8vgYfLUE+OSFV3p1Mks4RQwcLmSJlX0EuU/gayYDqvXz+PAyaqgPJ6ioI7eg0pta8Sr5dMY5K/smn7+fa/DJBOBwKvgmby3UTYG3qeCbvAXesQTuFl61VPr75y//o3F2bp9/oNW+2GF/1z04uY7rGX98os0a7LoHJxPtGFoUwlxDTtLZMdQ/WWHIyS87hvon63Yog6TV31hx9nY9/NlNVgMGV+96AjKS+3jNvmdpLWIW1YvR2Mh1qh5yqMr0LfoDb8BBZzPvwGk9AZUJu5oRm3S+ZhKYri9FDnPJF1SgkK9ZIUrnC+pRyNesLKXzBdUp5GtWpNJjA2pVIXzMSlZhn85bXMdzV8+KSk4Jfabk0zn86rtv8u5aPJCdcBsaeI3KlvFfj7H//i4pCWZK947T0KIgtcpRQn6WhmbJx0vdPR33PptyH9cyN/q4HPkX/DjltL6c5KRPadzBDJEt2AVJT8fwTNJz3vK+fq7B66Dc+KXILfY/gcXLwc/MojVv2ZFSkOlZ6WRhvAXlwDG/lLnFw2vV7fxPXHH7U4mbrKN38x3jcy/mL9g+L5g+32q5aytwGOW2pxLz1j9sN/3y8mXz/uvmc92e+uzS+415F78rN9vNY1M/7/vrSge3283u+PLSG9hu9sfd2f76YSz2r7on0L7wUPrHaLO9j7ZJ+S4l+vBhez9Vtg/sPyYb1//YirTZ3hOqSF5FciqazfbebOPiXWpSp6LxKhqnYrzZ3sfbhN5FceFUjL2KsVMx2WzvE9TUxKuYOBXTzfY+3cb0Lkpjp2LqVUyditlme59tTfauMIlTMfMqZk7FPDQ4uVcxdyoWocEpvIqFU7HcbO9z1MfSq1i6AOjxUEAE+NghAZ4eEiWsC+Dj4od6VFCEBol8CJGLIeqRQbRNkndJ5PaXfBiRiyPq0UEGVvahRC6WKAgm8tFELpyoBwnFcLx8RJELKcpDYCQfVOSiiorwWPvAIhdZVIbH2gcXuegyUQiXxkeXcdFlLDfBiDc+vIygJwuvFFYGDOXCy8RBcvPRZVx0GYuuDDr20WVcdJkguoyPLuOiywTZyvjgMi64TJCwjA8u44LLBDnL+NgyLrZMkLaMDy3jQivu0UI5GujYx1bsYiu22CoQqGMfW7GLrdhiq0TDFfvYisXyZ7EFYzEGK6ALrriHi4HrdeyDK3bBFQfBFfvgil1wxUFwxT64YhdccR4eax9dsYuuuAghJPbRFbvoinvAGEj0sQ+v2IVXEgUnOfHhlbjwSig4yYkPr8SFV2K3VnCZSHx4JS68kjhI9YkPr0TssCy8EjRgCdhkufBKesSYFFb28ZW4+Ep6yBjImokPsMQFWJKHgJ34+EpcfCVFCNiJj6/ExVdShsfax1fi4iuNgmOd+vhKXXylFBzr1MdX6uIrNaGQSn14pS68UstekHRTH16pC6/UwgsSQerDKxWbeAuvElYG+3gXXmkPmDiClX14pS680h4xMdwApT6+UhdfqV0dIQelPsBSF2BpD5k4hpV9gKUuwLIeMnGCFubMB1jmAizrIRNDgGU+wDIXYFmPmTiDlX2EZS7CMns+hAjLfIRlLsKyJMQEmQ+wzAVYlgZPez6+MnFQzILIzsBZ0cVXlgeRnfn4ylx8ZUUQ2ZmPr8zFV1YGkZ35+MpcfOVRENm5j6/cxVdOQWTnPr5yF1+5CSI79/GVu/jK4yCycx9fuYuvPAkiO/cBlrsAy9MQ7+Y+wHIXYLklMHhMz32A5SIbYQkMAiwHCQkXYHkPmQQCLPcBlrsAy3vIJBBguQ+w3AVY0UMmgQArfIAVLsCKHjIJBFjhA6xwAVb0kEng2lz4ACtcgBVxiIMKH1+Fi68iCXFQ4cOrcOFVpMFZLnx8FS6+iiw4y4WPr8LFV5EHZ7nw8VWIjFcRniiQ9HLxVZRhiPj4Klx8lcHcROnDq3ThVVp4wfRC6cOrdOFVWnhBFil9eJUuvMoeMQlcH0sfX6WLrzK4PpY+vkoXX2VwfSx9eJUuvMos3GMfXqULrzIPzpOPrtJFV2nRBXmz9NFVipxqGTwCliCtKvOqUfAMODxzq7P/jfUtxHB2NQLp1UjkV6MeNykM6eGZrC9SrFGQx4ZHsrpIskZBKhseyeoizRqlwbP78EzWF6nWKAvO3fBM1hfZ1iifmT2QcI1ExjUK5i2GR7K6yLlGPZhSLMxEIOsaCfDZRH2Khw/l9b3Efg+mFLIywdy+AJ/N16cwd0sovS/z+zZlj9GDEvwyw2+T9imkZ0I5fpnkH7L8MB1BKM8vE/02d5/maIdKKNUvc/02fx+aPoA+me+3KfzQ9AH4yZS/zeKn8DxDKOkvsv5kM/kp5i6Q+CeR+SebzM8wd4HcP4nkP9l8foY1C5D+J5H/J5vTz2BCnIAEQEIDIJvWz/D4AxWAhAxANrWfwQ0oASWAhBRAgxYADzkE1AAScgDZFH8G12sCigAJSYBsmj+DmxQCqgAJWYBsqj/DqiRQBkhIA2Sz/YH5A+IACXWAbMI/w/gF+gAJgYBszj/H+AUSAQmNgGzaP8f0D1QCEjIBxeHFF+gEJIQCssn/UPMB/IRYQPEM/QG9gIRgQFYDCE0fgJ/QDGgQDQwePgA/oRuQlQJyLNQC5YCEdEBWDcjx6gfEAxLqAVlBIMfLF9APSAgIZDWBHC9fQEIgoSGQlQVyHL5ARSAhI5BVBnIcvkBIIKEkUBI8ahCQEkhoCWTlgRxHL1ATSMgJZCWCAr5OQ0BRICEpkJUJChy9QFUgISuQVQoKDF8gLJBQFsiKBQVefYC2QEJcIKsXFBi+QF4goS+Q1QwKDF8gMZDQGMjKBgVefYDKQEJmIKscFDmuD+AnlAay4kGB4Qu0BhJiA6VBsZSA2kBCbiCrIIS6D+AnFAeyIkKo+wB+QnQgqyMUOHyA7EBCdyArJZQ4fIDyQEJ6IKsmlDh8gPhAQn0gKyiUOHyA/kBCgCCrKZSB13QA/IQGQdnM+0VAhiChQ5DVFkocfkCKIKFFkJUXShx+QI0gIUeQVRhKvHoAQYKEIkFWZCgx/oAmQUKUIKsz4OwrAVmChC5BVmoocfgCZYKENEFWbQgcfoA4QUKdICs4lDh+gD5BQqCgfJDwcQABjYKESEFWd6AIRxDQKUgIFWTFh0DqCGgVJMQKGtQKKNAQkCtI6BVkJYjA8Q8oFiQkC8qH990wBQDVgoRsQfmg7GMOAMoFCemCrBpBEQ5ioF6QkC/IKhIU4SgGCgYJCYOK4f1KHMZAxSAhY1AxwBDvAoGUQULLoGKAIQ5EoGeQEDTIahQBIgGSBglNg4qZYwhQNUjIGmSVCopwJANlg4S0QVatCKQhgLhBQt0gK1gEzkFA3yAhcJAVLQJpCKBxkBA5yOoWgTQEkDlI6BxkpYtAGgIoHSSkDrLqRSANAcQOEmoHWQWDCDMpUDxISB5kZQwKvfwKICh0D7JSBhFmIiB9kNA+qAy/9gvUDxLyB1lFI9gBAEEhgdCggQReWgYqCAkZxFhZgwKv8QIdxAgdxFhdgwKv8gIhxAghxFhhAx+HDBBCjBBCTDS8bI5f6QVSiBFSiIkGGEImNUAMMUIMMdEAQ8ikBqghRqghZlBDCDKZAXKIEXKIsfIGGRhJBughRughxgochF9dNUARMUIRMVbhIAMjyQBJxAhJxFiJg/B7lQZoItP/7Kd2PtdtV+9/Hj69c3/v32rzdfMwfranjKcPDn3d9GeD91+/fbt+mOf912/s8zz9s97pYA5ZS67GEp0p9/Kaq6XrhzW/bigZavdnD6XR6cq/q8E84waNytDH8c49ZqXgVnTNGazUwxWgzBQxU7Fu5AdTl3uo2TTy4crW9O562zQb/Ih3s1B2014JxJqU8CalShvTt5le7ZiUgSodKqakM+cYSqKrobwYqhSZytDlS11ZszjWR4Cmkc4a/1Tx1SIDaDYCPh9+mvHvePyZjK3PxuEoRv/l+HevTw+/UDz+Ek//mYawT+QMv5S5ttXi6iU21ymfa+WYOlcpMVs8ULNVbUNNI2bO6JA8WhsvlGFTziI2XmVK9tGwRiUrhuuLA8GURxjpgn43fkXn1UqcM9zpaOhyAwibtZzP2ojP/oyjsgcuzLyaLjhRDtBXW/RsRdyWElxtva8PXVM9Oz0mh7+nWNJZnK6s4ZTLQ6jQ8dvlDh1uh4OiUBLSdDsQt8NXlFw56tPlQNwO32DkuvEZvjGJMTcLl9KMXKcLm/1whbFdfXf2lmO2AvOhSnRDNX2Mn+OAM0yki6DrxZMMm5z40glRqQ6k0zeXsrhmE5iOAZkrO2m/wYERTclaZnQ2EBezFo2zOC1wY2+NrrP23hxmlgV1PFpKaFoKo+kXpW33ymaGFs5wiW6aL1+Ly6aFNXZagjNdeE23q7M2cTqLzbTW61j30d1+xGyS83FelPwtySzjXGYu+wzdCsXuHWQMwEK1mPqZ6diEfx0WwzSbhinURhCOP+PxZzIORjZuoYoRYuW0pVLG/Cd3A9kfsRlv6PYTl4tiuB2+EF22gKUOU/KY1J/bWat08z/d3Mi2A/wAoWSd4Wsu2Zyzfg0Lx3ZT6IJYrooZZ3pzGSLdKsu+75hFC8NPNnJNrsNBEz4/85m8xZbDNIwbdBPQ9Kfxfqezs8dxuWnlU7rCIDze8wVF2dOP1alGm2A+ZpGOERp2JL9cQMPQwntqVpkE1nJOhomys3MHGmbP6Ci1CZ1oOAXFyjkNHWnYPk+ZXmn8w4hhDUqV4eTwV8rnjkbW6EVPlSkYR8TIwygN+fe8Muxz8OfKoXqc7HnmEjZkpXLI4Bobs6HLdbvr5nG8x/+F3RTOGIPTrjJnI25IZ8b4/iud1t5Ut8w1T9MFnPxowi0WyqEbDX0e7gfl1vj2vVBO69y8sjhXrlQNnoaS7xIzZZhfTb2O37LApoJTWqrs6vX6YdYyTt6ZspMeiyUMuMptT9PaW64AXfcCHNtO6bZlzfhNJoz1+bE3Vq4h1kpjvySIWeIH8VjZvR6fzV5yNDnJfp2l4TtY2WBz9op0UyaTCxnvkrmkKKeTWjxuuHuFWGP+t9pNGDM4TDyhzEnab8NkiwnHOemg8FJ9eahcfMYM5el0mNB17fItaGzw+ATEugm43P3Icc7BUCqbg2mer7mp7sjg3BLJW8UX20jHVfbeRtYc3rF0BFWvk6psuVkwZ7OlRDsklZLb0YFxvLqQa2IRt6I7Cr1WnWMkZqOTjvsj5fboVRzQiCfEewlUZaRfBhxCYWRQjLOlDHy0pPSvorHtlW5Vt7ct8o7xTJXyUPH7g7cnYEM9ktyYhzO6sRov0Oed4wtmrmMkb71M+QhFUzYl1wUuEB9TTuc0sTjp8In0x5RHME2ZPNINWjveEMyjjzdQmTi2357DSIWzbqpDxKl6qR9OjZsujrnMN2VBdRN5qnetm0NK2EAV4zgVumGavr+AtYxRbzbOYn5Bh27UTiI1m3G8msmYuonT/dfMIN9lm2mjoDz3CAm3f1mObdl1eGVfZ80Ay1tFukA6Nf92N2dsFY3HuYyVbfJ2n7xjSoHx1FWtOzzMyJRbTkZclNPJWrmWTVf4syHjhy+acsSTwk1KYV5mgzLOHGY6JJa6fcT4/b3MGm+jkrO7o59r5if0aTsy/hz/jseFIZ0YYcxRltN4T0hXrrLjFb98SWMjY5T5O+/8wBeOSzCPyJiEFx03ymMFX3HjC7h0kWS/EZzNmtPKafXIdaF0dleiwnkNSdc1bxtgWCCl48RqXmz4sN28Nq/1c3OoN+/vP3z79v88rFOl"; diff --git a/packages/binden/docs/assets/style.css b/packages/binden/docs/assets/style.css deleted file mode 100644 index 02473c22..00000000 --- a/packages/binden/docs/assets/style.css +++ /dev/null @@ -1,1602 +0,0 @@ -@layer typedoc { - :root { - /* Light */ - --light-color-background: #f2f4f8; - --light-color-background-secondary: #eff0f1; - --light-color-warning-text: #222; - --light-color-background-warning: #e6e600; - --light-color-accent: #c5c7c9; - --light-color-active-menu-item: var(--light-color-accent); - --light-color-text: #222; - --light-color-text-aside: #6e6e6e; - - --light-color-icon-background: var(--light-color-background); - --light-color-icon-text: var(--light-color-text); - - --light-color-comment-tag-text: var(--light-color-text); - --light-color-comment-tag: var(--light-color-background); - - --light-color-link: #1f70c2; - --light-color-focus-outline: #3584e4; - - --light-color-ts-keyword: #056bd6; - --light-color-ts-project: #b111c9; - --light-color-ts-module: var(--light-color-ts-project); - --light-color-ts-namespace: var(--light-color-ts-project); - --light-color-ts-enum: #7e6f15; - --light-color-ts-enum-member: var(--light-color-ts-enum); - --light-color-ts-variable: #4760ec; - --light-color-ts-function: #572be7; - --light-color-ts-class: #1f70c2; - --light-color-ts-interface: #108024; - --light-color-ts-constructor: var(--light-color-ts-class); - --light-color-ts-property: #9f5f30; - --light-color-ts-method: #be3989; - --light-color-ts-reference: #ff4d82; - --light-color-ts-call-signature: var(--light-color-ts-method); - --light-color-ts-index-signature: var(--light-color-ts-property); - --light-color-ts-constructor-signature: var(--light-color-ts-constructor); - --light-color-ts-parameter: var(--light-color-ts-variable); - /* type literal not included as links will never be generated to it */ - --light-color-ts-type-parameter: #a55c0e; - --light-color-ts-accessor: #c73c3c; - --light-color-ts-get-signature: var(--light-color-ts-accessor); - --light-color-ts-set-signature: var(--light-color-ts-accessor); - --light-color-ts-type-alias: #d51270; - /* reference not included as links will be colored with the kind that it points to */ - --light-color-document: #000000; - - --light-color-alert-note: #0969d9; - --light-color-alert-tip: #1a7f37; - --light-color-alert-important: #8250df; - --light-color-alert-warning: #9a6700; - --light-color-alert-caution: #cf222e; - - --light-external-icon: url("data:image/svg+xml;utf8,"); - --light-color-scheme: light; - - /* Dark */ - --dark-color-background: #2b2e33; - --dark-color-background-secondary: #1e2024; - --dark-color-background-warning: #bebe00; - --dark-color-warning-text: #222; - --dark-color-accent: #9096a2; - --dark-color-active-menu-item: #5d5d6a; - --dark-color-text: #f5f5f5; - --dark-color-text-aside: #dddddd; - - --dark-color-icon-background: var(--dark-color-background-secondary); - --dark-color-icon-text: var(--dark-color-text); - - --dark-color-comment-tag-text: var(--dark-color-text); - --dark-color-comment-tag: var(--dark-color-background); - - --dark-color-link: #00aff4; - --dark-color-focus-outline: #4c97f2; - - --dark-color-ts-keyword: #3399ff; - --dark-color-ts-project: #e358ff; - --dark-color-ts-module: var(--dark-color-ts-project); - --dark-color-ts-namespace: var(--dark-color-ts-project); - --dark-color-ts-enum: #f4d93e; - --dark-color-ts-enum-member: var(--dark-color-ts-enum); - --dark-color-ts-variable: #798dff; - --dark-color-ts-function: #a280ff; - --dark-color-ts-class: #8ac4ff; - --dark-color-ts-interface: #6cff87; - --dark-color-ts-constructor: var(--dark-color-ts-class); - --dark-color-ts-property: #ff984d; - --dark-color-ts-method: #ff4db8; - --dark-color-ts-reference: #ff4d82; - --dark-color-ts-call-signature: var(--dark-color-ts-method); - --dark-color-ts-index-signature: var(--dark-color-ts-property); - --dark-color-ts-constructor-signature: var(--dark-color-ts-constructor); - --dark-color-ts-parameter: var(--dark-color-ts-variable); - /* type literal not included as links will never be generated to it */ - --dark-color-ts-type-parameter: #e07d13; - --dark-color-ts-accessor: #ff6060; - --dark-color-ts-get-signature: var(--dark-color-ts-accessor); - --dark-color-ts-set-signature: var(--dark-color-ts-accessor); - --dark-color-ts-type-alias: #ff6492; - /* reference not included as links will be colored with the kind that it points to */ - --dark-color-document: #ffffff; - - --dark-color-alert-note: #0969d9; - --dark-color-alert-tip: #1a7f37; - --dark-color-alert-important: #8250df; - --dark-color-alert-warning: #9a6700; - --dark-color-alert-caution: #cf222e; - - --dark-external-icon: url("data:image/svg+xml;utf8,"); - --dark-color-scheme: dark; - } - - @media (prefers-color-scheme: light) { - :root { - --color-background: var(--light-color-background); - --color-background-secondary: var(--light-color-background-secondary); - --color-background-warning: var(--light-color-background-warning); - --color-warning-text: var(--light-color-warning-text); - --color-accent: var(--light-color-accent); - --color-active-menu-item: var(--light-color-active-menu-item); - --color-text: var(--light-color-text); - --color-text-aside: var(--light-color-text-aside); - - --color-icon-background: var(--light-color-icon-background); - --color-icon-text: var(--light-color-icon-text); - - --color-comment-tag-text: var(--light-color-text); - --color-comment-tag: var(--light-color-background); - - --color-link: var(--light-color-link); - --color-focus-outline: var(--light-color-focus-outline); - - --color-ts-keyword: var(--light-color-ts-keyword); - --color-ts-project: var(--light-color-ts-project); - --color-ts-module: var(--light-color-ts-module); - --color-ts-namespace: var(--light-color-ts-namespace); - --color-ts-enum: var(--light-color-ts-enum); - --color-ts-enum-member: var(--light-color-ts-enum-member); - --color-ts-variable: var(--light-color-ts-variable); - --color-ts-function: var(--light-color-ts-function); - --color-ts-class: var(--light-color-ts-class); - --color-ts-interface: var(--light-color-ts-interface); - --color-ts-constructor: var(--light-color-ts-constructor); - --color-ts-property: var(--light-color-ts-property); - --color-ts-method: var(--light-color-ts-method); - --color-ts-reference: var(--light-color-ts-reference); - --color-ts-call-signature: var(--light-color-ts-call-signature); - --color-ts-index-signature: var(--light-color-ts-index-signature); - --color-ts-constructor-signature: var( - --light-color-ts-constructor-signature - ); - --color-ts-parameter: var(--light-color-ts-parameter); - --color-ts-type-parameter: var(--light-color-ts-type-parameter); - --color-ts-accessor: var(--light-color-ts-accessor); - --color-ts-get-signature: var(--light-color-ts-get-signature); - --color-ts-set-signature: var(--light-color-ts-set-signature); - --color-ts-type-alias: var(--light-color-ts-type-alias); - --color-document: var(--light-color-document); - - --color-alert-note: var(--light-color-alert-note); - --color-alert-tip: var(--light-color-alert-tip); - --color-alert-important: var(--light-color-alert-important); - --color-alert-warning: var(--light-color-alert-warning); - --color-alert-caution: var(--light-color-alert-caution); - - --external-icon: var(--light-external-icon); - --color-scheme: var(--light-color-scheme); - } - } - - @media (prefers-color-scheme: dark) { - :root { - --color-background: var(--dark-color-background); - --color-background-secondary: var(--dark-color-background-secondary); - --color-background-warning: var(--dark-color-background-warning); - --color-warning-text: var(--dark-color-warning-text); - --color-accent: var(--dark-color-accent); - --color-active-menu-item: var(--dark-color-active-menu-item); - --color-text: var(--dark-color-text); - --color-text-aside: var(--dark-color-text-aside); - - --color-icon-background: var(--dark-color-icon-background); - --color-icon-text: var(--dark-color-icon-text); - - --color-comment-tag-text: var(--dark-color-text); - --color-comment-tag: var(--dark-color-background); - - --color-link: var(--dark-color-link); - --color-focus-outline: var(--dark-color-focus-outline); - - --color-ts-keyword: var(--dark-color-ts-keyword); - --color-ts-project: var(--dark-color-ts-project); - --color-ts-module: var(--dark-color-ts-module); - --color-ts-namespace: var(--dark-color-ts-namespace); - --color-ts-enum: var(--dark-color-ts-enum); - --color-ts-enum-member: var(--dark-color-ts-enum-member); - --color-ts-variable: var(--dark-color-ts-variable); - --color-ts-function: var(--dark-color-ts-function); - --color-ts-class: var(--dark-color-ts-class); - --color-ts-interface: var(--dark-color-ts-interface); - --color-ts-constructor: var(--dark-color-ts-constructor); - --color-ts-property: var(--dark-color-ts-property); - --color-ts-method: var(--dark-color-ts-method); - --color-ts-reference: var(--dark-color-ts-reference); - --color-ts-call-signature: var(--dark-color-ts-call-signature); - --color-ts-index-signature: var(--dark-color-ts-index-signature); - --color-ts-constructor-signature: var( - --dark-color-ts-constructor-signature - ); - --color-ts-parameter: var(--dark-color-ts-parameter); - --color-ts-type-parameter: var(--dark-color-ts-type-parameter); - --color-ts-accessor: var(--dark-color-ts-accessor); - --color-ts-get-signature: var(--dark-color-ts-get-signature); - --color-ts-set-signature: var(--dark-color-ts-set-signature); - --color-ts-type-alias: var(--dark-color-ts-type-alias); - --color-document: var(--dark-color-document); - - --color-alert-note: var(--dark-color-alert-note); - --color-alert-tip: var(--dark-color-alert-tip); - --color-alert-important: var(--dark-color-alert-important); - --color-alert-warning: var(--dark-color-alert-warning); - --color-alert-caution: var(--dark-color-alert-caution); - - --external-icon: var(--dark-external-icon); - --color-scheme: var(--dark-color-scheme); - } - } - - html { - color-scheme: var(--color-scheme); - } - - body { - margin: 0; - } - - :root[data-theme="light"] { - --color-background: var(--light-color-background); - --color-background-secondary: var(--light-color-background-secondary); - --color-background-warning: var(--light-color-background-warning); - --color-warning-text: var(--light-color-warning-text); - --color-icon-background: var(--light-color-icon-background); - --color-accent: var(--light-color-accent); - --color-active-menu-item: var(--light-color-active-menu-item); - --color-text: var(--light-color-text); - --color-text-aside: var(--light-color-text-aside); - --color-icon-text: var(--light-color-icon-text); - - --color-comment-tag-text: var(--light-color-text); - --color-comment-tag: var(--light-color-background); - - --color-link: var(--light-color-link); - --color-focus-outline: var(--light-color-focus-outline); - - --color-ts-keyword: var(--light-color-ts-keyword); - --color-ts-project: var(--light-color-ts-project); - --color-ts-module: var(--light-color-ts-module); - --color-ts-namespace: var(--light-color-ts-namespace); - --color-ts-enum: var(--light-color-ts-enum); - --color-ts-enum-member: var(--light-color-ts-enum-member); - --color-ts-variable: var(--light-color-ts-variable); - --color-ts-function: var(--light-color-ts-function); - --color-ts-class: var(--light-color-ts-class); - --color-ts-interface: var(--light-color-ts-interface); - --color-ts-constructor: var(--light-color-ts-constructor); - --color-ts-property: var(--light-color-ts-property); - --color-ts-method: var(--light-color-ts-method); - --color-ts-reference: var(--light-color-ts-reference); - --color-ts-call-signature: var(--light-color-ts-call-signature); - --color-ts-index-signature: var(--light-color-ts-index-signature); - --color-ts-constructor-signature: var( - --light-color-ts-constructor-signature - ); - --color-ts-parameter: var(--light-color-ts-parameter); - --color-ts-type-parameter: var(--light-color-ts-type-parameter); - --color-ts-accessor: var(--light-color-ts-accessor); - --color-ts-get-signature: var(--light-color-ts-get-signature); - --color-ts-set-signature: var(--light-color-ts-set-signature); - --color-ts-type-alias: var(--light-color-ts-type-alias); - --color-document: var(--light-color-document); - - --color-note: var(--light-color-note); - --color-tip: var(--light-color-tip); - --color-important: var(--light-color-important); - --color-warning: var(--light-color-warning); - --color-caution: var(--light-color-caution); - - --external-icon: var(--light-external-icon); - --color-scheme: var(--light-color-scheme); - } - - :root[data-theme="dark"] { - --color-background: var(--dark-color-background); - --color-background-secondary: var(--dark-color-background-secondary); - --color-background-warning: var(--dark-color-background-warning); - --color-warning-text: var(--dark-color-warning-text); - --color-icon-background: var(--dark-color-icon-background); - --color-accent: var(--dark-color-accent); - --color-active-menu-item: var(--dark-color-active-menu-item); - --color-text: var(--dark-color-text); - --color-text-aside: var(--dark-color-text-aside); - --color-icon-text: var(--dark-color-icon-text); - - --color-comment-tag-text: var(--dark-color-text); - --color-comment-tag: var(--dark-color-background); - - --color-link: var(--dark-color-link); - --color-focus-outline: var(--dark-color-focus-outline); - - --color-ts-keyword: var(--dark-color-ts-keyword); - --color-ts-project: var(--dark-color-ts-project); - --color-ts-module: var(--dark-color-ts-module); - --color-ts-namespace: var(--dark-color-ts-namespace); - --color-ts-enum: var(--dark-color-ts-enum); - --color-ts-enum-member: var(--dark-color-ts-enum-member); - --color-ts-variable: var(--dark-color-ts-variable); - --color-ts-function: var(--dark-color-ts-function); - --color-ts-class: var(--dark-color-ts-class); - --color-ts-interface: var(--dark-color-ts-interface); - --color-ts-constructor: var(--dark-color-ts-constructor); - --color-ts-property: var(--dark-color-ts-property); - --color-ts-method: var(--dark-color-ts-method); - --color-ts-reference: var(--dark-color-ts-reference); - --color-ts-call-signature: var(--dark-color-ts-call-signature); - --color-ts-index-signature: var(--dark-color-ts-index-signature); - --color-ts-constructor-signature: var( - --dark-color-ts-constructor-signature - ); - --color-ts-parameter: var(--dark-color-ts-parameter); - --color-ts-type-parameter: var(--dark-color-ts-type-parameter); - --color-ts-accessor: var(--dark-color-ts-accessor); - --color-ts-get-signature: var(--dark-color-ts-get-signature); - --color-ts-set-signature: var(--dark-color-ts-set-signature); - --color-ts-type-alias: var(--dark-color-ts-type-alias); - --color-document: var(--dark-color-document); - - --color-note: var(--dark-color-note); - --color-tip: var(--dark-color-tip); - --color-important: var(--dark-color-important); - --color-warning: var(--dark-color-warning); - --color-caution: var(--dark-color-caution); - - --external-icon: var(--dark-external-icon); - --color-scheme: var(--dark-color-scheme); - } - - *:focus-visible, - .tsd-accordion-summary:focus-visible svg { - outline: 2px solid var(--color-focus-outline); - } - - .always-visible, - .always-visible .tsd-signatures { - display: inherit !important; - } - - h1, - h2, - h3, - h4, - h5, - h6 { - line-height: 1.2; - } - - h1 { - font-size: 1.875rem; - margin: 0.67rem 0; - } - - h2 { - font-size: 1.5rem; - margin: 0.83rem 0; - } - - h3 { - font-size: 1.25rem; - margin: 1rem 0; - } - - h4 { - font-size: 1.05rem; - margin: 1.33rem 0; - } - - h5 { - font-size: 1rem; - margin: 1.5rem 0; - } - - h6 { - font-size: 0.875rem; - margin: 2.33rem 0; - } - - dl, - menu, - ol, - ul { - margin: 1em 0; - } - - dd { - margin: 0 0 0 34px; - } - - .container { - max-width: 1700px; - padding: 0 2rem; - } - - /* Footer */ - footer { - border-top: 1px solid var(--color-accent); - padding-top: 1rem; - padding-bottom: 1rem; - max-height: 3.5rem; - } - footer > p { - margin: 0 1em; - } - - .container-main { - margin: 0 auto; - /* toolbar, footer, margin */ - min-height: calc(100vh - 41px - 56px - 4rem); - } - - @keyframes fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } - } - @keyframes fade-out { - from { - opacity: 1; - visibility: visible; - } - to { - opacity: 0; - } - } - @keyframes fade-in-delayed { - 0% { - opacity: 0; - } - 33% { - opacity: 0; - } - 100% { - opacity: 1; - } - } - @keyframes fade-out-delayed { - 0% { - opacity: 1; - visibility: visible; - } - 66% { - opacity: 0; - } - 100% { - opacity: 0; - } - } - @keyframes pop-in-from-right { - from { - transform: translate(100%, 0); - } - to { - transform: translate(0, 0); - } - } - @keyframes pop-out-to-right { - from { - transform: translate(0, 0); - visibility: visible; - } - to { - transform: translate(100%, 0); - } - } - body { - background: var(--color-background); - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, - Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; - font-size: 16px; - color: var(--color-text); - } - - a { - color: var(--color-link); - text-decoration: none; - } - a:hover { - text-decoration: underline; - } - a.external[target="_blank"] { - background-image: var(--external-icon); - background-position: top 3px right; - background-repeat: no-repeat; - padding-right: 13px; - } - a.tsd-anchor-link { - color: var(--color-text); - } - - code, - pre { - font-family: Menlo, Monaco, Consolas, "Courier New", monospace; - padding: 0.2em; - margin: 0; - font-size: 0.875rem; - border-radius: 0.8em; - } - - pre { - position: relative; - white-space: pre-wrap; - word-wrap: break-word; - padding: 10px; - border: 1px solid var(--color-accent); - margin-bottom: 8px; - } - pre code { - padding: 0; - font-size: 100%; - } - pre > button { - position: absolute; - top: 10px; - right: 10px; - opacity: 0; - transition: opacity 0.1s; - box-sizing: border-box; - } - pre:hover > button, - pre > button.visible { - opacity: 1; - } - - blockquote { - margin: 1em 0; - padding-left: 1em; - border-left: 4px solid gray; - } - - .tsd-typography { - line-height: 1.333em; - } - .tsd-typography ul { - list-style: square; - padding: 0 0 0 20px; - margin: 0; - } - .tsd-typography .tsd-index-panel h3, - .tsd-index-panel .tsd-typography h3, - .tsd-typography h4, - .tsd-typography h5, - .tsd-typography h6 { - font-size: 1em; - } - .tsd-typography h5, - .tsd-typography h6 { - font-weight: normal; - } - .tsd-typography p, - .tsd-typography ul, - .tsd-typography ol { - margin: 1em 0; - } - .tsd-typography table { - border-collapse: collapse; - border: none; - } - .tsd-typography td, - .tsd-typography th { - padding: 6px 13px; - border: 1px solid var(--color-accent); - } - .tsd-typography thead, - .tsd-typography tr:nth-child(even) { - background-color: var(--color-background-secondary); - } - - .tsd-alert { - padding: 8px 16px; - margin-bottom: 16px; - border-left: 0.25em solid var(--alert-color); - } - .tsd-alert blockquote > :last-child, - .tsd-alert > :last-child { - margin-bottom: 0; - } - .tsd-alert-title { - color: var(--alert-color); - display: inline-flex; - align-items: center; - } - .tsd-alert-title span { - margin-left: 4px; - } - - .tsd-alert-note { - --alert-color: var(--color-alert-note); - } - .tsd-alert-tip { - --alert-color: var(--color-alert-tip); - } - .tsd-alert-important { - --alert-color: var(--color-alert-important); - } - .tsd-alert-warning { - --alert-color: var(--color-alert-warning); - } - .tsd-alert-caution { - --alert-color: var(--color-alert-caution); - } - - .tsd-breadcrumb { - margin: 0; - padding: 0; - color: var(--color-text-aside); - } - .tsd-breadcrumb a { - color: var(--color-text-aside); - text-decoration: none; - } - .tsd-breadcrumb a:hover { - text-decoration: underline; - } - .tsd-breadcrumb li { - display: inline; - } - .tsd-breadcrumb li:after { - content: " / "; - } - - .tsd-comment-tags { - display: flex; - flex-direction: column; - } - dl.tsd-comment-tag-group { - display: flex; - align-items: center; - overflow: hidden; - margin: 0.5em 0; - } - dl.tsd-comment-tag-group dt { - display: flex; - margin-right: 0.5em; - font-size: 0.875em; - font-weight: normal; - } - dl.tsd-comment-tag-group dd { - margin: 0; - } - code.tsd-tag { - padding: 0.25em 0.4em; - border: 0.1em solid var(--color-accent); - margin-right: 0.25em; - font-size: 70%; - } - h1 code.tsd-tag:first-of-type { - margin-left: 0.25em; - } - - dl.tsd-comment-tag-group dd:before, - dl.tsd-comment-tag-group dd:after { - content: " "; - } - dl.tsd-comment-tag-group dd pre, - dl.tsd-comment-tag-group dd:after { - clear: both; - } - dl.tsd-comment-tag-group p { - margin: 0; - } - - .tsd-panel.tsd-comment .lead { - font-size: 1.1em; - line-height: 1.333em; - margin-bottom: 2em; - } - .tsd-panel.tsd-comment .lead:last-child { - margin-bottom: 0; - } - - .tsd-filter-visibility h4 { - font-size: 1rem; - padding-top: 0.75rem; - padding-bottom: 0.5rem; - margin: 0; - } - .tsd-filter-item:not(:last-child) { - margin-bottom: 0.5rem; - } - .tsd-filter-input { - display: flex; - width: -moz-fit-content; - width: fit-content; - align-items: center; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - cursor: pointer; - } - .tsd-filter-input input[type="checkbox"] { - cursor: pointer; - position: absolute; - width: 1.5em; - height: 1.5em; - opacity: 0; - } - .tsd-filter-input input[type="checkbox"]:disabled { - pointer-events: none; - } - .tsd-filter-input svg { - cursor: pointer; - width: 1.5em; - height: 1.5em; - margin-right: 0.5em; - border-radius: 0.33em; - /* Leaving this at full opacity breaks event listeners on Firefox. - Don't remove unless you know what you're doing. */ - opacity: 0.99; - } - .tsd-filter-input input[type="checkbox"]:focus-visible + svg { - outline: 2px solid var(--color-focus-outline); - } - .tsd-checkbox-background { - fill: var(--color-accent); - } - input[type="checkbox"]:checked ~ svg .tsd-checkbox-checkmark { - stroke: var(--color-text); - } - .tsd-filter-input input:disabled ~ svg > .tsd-checkbox-background { - fill: var(--color-background); - stroke: var(--color-accent); - stroke-width: 0.25rem; - } - .tsd-filter-input input:disabled ~ svg > .tsd-checkbox-checkmark { - stroke: var(--color-accent); - } - - .settings-label { - font-weight: bold; - text-transform: uppercase; - display: inline-block; - } - - .tsd-filter-visibility .settings-label { - margin: 0.75rem 0 0.5rem 0; - } - - .tsd-theme-toggle .settings-label { - margin: 0.75rem 0.75rem 0 0; - } - - .tsd-hierarchy h4 label:hover span { - text-decoration: underline; - } - - .tsd-hierarchy { - list-style: square; - margin: 0; - } - .tsd-hierarchy-target { - font-weight: bold; - } - .tsd-hierarchy-toggle { - color: var(--color-link); - cursor: pointer; - } - - .tsd-full-hierarchy:not(:last-child) { - margin-bottom: 1em; - padding-bottom: 1em; - border-bottom: 1px solid var(--color-accent); - } - .tsd-full-hierarchy, - .tsd-full-hierarchy ul { - list-style: none; - margin: 0; - padding: 0; - } - .tsd-full-hierarchy ul { - padding-left: 1.5rem; - } - .tsd-full-hierarchy a { - padding: 0.25rem 0 !important; - font-size: 1rem; - display: inline-flex; - align-items: center; - color: var(--color-text); - } - .tsd-full-hierarchy svg[data-dropdown] { - cursor: pointer; - } - .tsd-full-hierarchy svg[data-dropdown="false"] { - transform: rotate(-90deg); - } - .tsd-full-hierarchy svg[data-dropdown="false"] ~ ul { - display: none; - } - - .tsd-panel-group.tsd-index-group { - margin-bottom: 0; - } - .tsd-index-panel .tsd-index-list { - list-style: none; - line-height: 1.333em; - margin: 0; - padding: 0.25rem 0 0 0; - overflow: hidden; - display: grid; - grid-template-columns: repeat(3, 1fr); - column-gap: 1rem; - grid-template-rows: auto; - } - @media (max-width: 1024px) { - .tsd-index-panel .tsd-index-list { - grid-template-columns: repeat(2, 1fr); - } - } - @media (max-width: 768px) { - .tsd-index-panel .tsd-index-list { - grid-template-columns: repeat(1, 1fr); - } - } - .tsd-index-panel .tsd-index-list li { - -webkit-page-break-inside: avoid; - -moz-page-break-inside: avoid; - -ms-page-break-inside: avoid; - -o-page-break-inside: avoid; - page-break-inside: avoid; - } - - .tsd-flag { - display: inline-block; - padding: 0.25em 0.4em; - border-radius: 4px; - color: var(--color-comment-tag-text); - background-color: var(--color-comment-tag); - text-indent: 0; - font-size: 75%; - line-height: 1; - font-weight: normal; - } - - .tsd-anchor { - position: relative; - top: -100px; - } - - .tsd-member { - position: relative; - } - .tsd-member .tsd-anchor + h3 { - display: flex; - align-items: center; - margin-top: 0; - margin-bottom: 0; - border-bottom: none; - } - - .tsd-navigation.settings { - margin: 1rem 0; - } - .tsd-navigation > a, - .tsd-navigation .tsd-accordion-summary { - width: calc(100% - 0.25rem); - display: flex; - align-items: center; - } - .tsd-navigation a, - .tsd-navigation summary > span, - .tsd-page-navigation a { - display: flex; - width: calc(100% - 0.25rem); - align-items: center; - padding: 0.25rem; - color: var(--color-text); - text-decoration: none; - box-sizing: border-box; - } - .tsd-navigation a.current, - .tsd-page-navigation a.current { - background: var(--color-active-menu-item); - } - .tsd-navigation a:hover, - .tsd-page-navigation a:hover { - text-decoration: underline; - } - .tsd-navigation ul, - .tsd-page-navigation ul { - margin-top: 0; - margin-bottom: 0; - padding: 0; - list-style: none; - } - .tsd-navigation li, - .tsd-page-navigation li { - padding: 0; - max-width: 100%; - } - .tsd-navigation .tsd-nav-link { - display: none; - } - .tsd-nested-navigation { - margin-left: 3rem; - } - .tsd-nested-navigation > li > details { - margin-left: -1.5rem; - } - .tsd-small-nested-navigation { - margin-left: 1.5rem; - } - .tsd-small-nested-navigation > li > details { - margin-left: -1.5rem; - } - - .tsd-page-navigation-section { - margin-left: 10px; - } - .tsd-page-navigation-section > summary { - padding: 0.25rem; - } - .tsd-page-navigation-section > div { - margin-left: 20px; - } - .tsd-page-navigation ul { - padding-left: 1.75rem; - } - - #tsd-sidebar-links a { - margin-top: 0; - margin-bottom: 0.5rem; - line-height: 1.25rem; - } - #tsd-sidebar-links a:last-of-type { - margin-bottom: 0; - } - - a.tsd-index-link { - padding: 0.25rem 0 !important; - font-size: 1rem; - line-height: 1.25rem; - display: inline-flex; - align-items: center; - color: var(--color-text); - } - .tsd-accordion-summary { - list-style-type: none; /* hide marker on non-safari */ - outline: none; /* broken on safari, so just hide it */ - } - .tsd-accordion-summary::-webkit-details-marker { - display: none; /* hide marker on safari */ - } - .tsd-accordion-summary, - .tsd-accordion-summary a { - -moz-user-select: none; - -webkit-user-select: none; - -ms-user-select: none; - user-select: none; - - cursor: pointer; - } - .tsd-accordion-summary a { - width: calc(100% - 1.5rem); - } - .tsd-accordion-summary > * { - margin-top: 0; - margin-bottom: 0; - padding-top: 0; - padding-bottom: 0; - } - .tsd-accordion .tsd-accordion-summary > svg { - margin-left: 0.25rem; - vertical-align: text-top; - } - /* - We need to be careful to target the arrow indicating whether the accordion - is open, but not any other SVGs included in the details element. -*/ - .tsd-accordion:not([open]) > .tsd-accordion-summary > svg:first-child, - .tsd-accordion:not([open]) > .tsd-accordion-summary > h1 > svg:first-child, - .tsd-accordion:not([open]) > .tsd-accordion-summary > h2 > svg:first-child, - .tsd-accordion:not([open]) > .tsd-accordion-summary > h3 > svg:first-child, - .tsd-accordion:not([open]) > .tsd-accordion-summary > h4 > svg:first-child { - transform: rotate(-90deg); - } - .tsd-index-content > :not(:first-child) { - margin-top: 0.75rem; - } - .tsd-index-heading { - margin-top: 1.5rem; - margin-bottom: 0.75rem; - } - - .tsd-no-select { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - } - .tsd-kind-icon { - margin-right: 0.5rem; - width: 1.25rem; - height: 1.25rem; - min-width: 1.25rem; - min-height: 1.25rem; - } - .tsd-signature > .tsd-kind-icon { - margin-right: 0.8rem; - } - - .tsd-panel { - margin-bottom: 2.5rem; - } - .tsd-panel.tsd-member { - margin-bottom: 4rem; - } - .tsd-panel:empty { - display: none; - } - .tsd-panel > h1, - .tsd-panel > h2, - .tsd-panel > h3 { - margin: 1.5rem -1.5rem 0.75rem -1.5rem; - padding: 0 1.5rem 0.75rem 1.5rem; - } - .tsd-panel > h1.tsd-before-signature, - .tsd-panel > h2.tsd-before-signature, - .tsd-panel > h3.tsd-before-signature { - margin-bottom: 0; - border-bottom: none; - } - - .tsd-panel-group { - margin: 2rem 0; - } - .tsd-panel-group.tsd-index-group { - margin: 2rem 0; - } - .tsd-panel-group.tsd-index-group details { - margin: 2rem 0; - } - .tsd-panel-group > .tsd-accordion-summary { - margin-bottom: 1rem; - } - - #tsd-search { - transition: background-color 0.2s; - } - #tsd-search .title { - position: relative; - z-index: 2; - } - #tsd-search .field { - position: absolute; - left: 0; - top: 0; - right: 2.5rem; - height: 100%; - } - #tsd-search .field input { - box-sizing: border-box; - position: relative; - top: -50px; - z-index: 1; - width: 100%; - padding: 0 10px; - opacity: 0; - outline: 0; - border: 0; - background: transparent; - color: var(--color-text); - } - #tsd-search .field label { - position: absolute; - overflow: hidden; - right: -40px; - } - #tsd-search .field input, - #tsd-search .title, - #tsd-toolbar-links a { - transition: opacity 0.2s; - } - #tsd-search .results { - position: absolute; - visibility: hidden; - top: 40px; - width: 100%; - margin: 0; - padding: 0; - list-style: none; - box-shadow: 0 0 4px rgba(0, 0, 0, 0.25); - } - #tsd-search .results li { - background-color: var(--color-background); - line-height: initial; - padding: 4px; - } - #tsd-search .results li:nth-child(even) { - background-color: var(--color-background-secondary); - } - #tsd-search .results li.state { - display: none; - } - #tsd-search .results li.current:not(.no-results), - #tsd-search .results li:hover:not(.no-results) { - background-color: var(--color-accent); - } - #tsd-search .results a { - display: flex; - align-items: center; - padding: 0.25rem; - box-sizing: border-box; - } - #tsd-search .results a:before { - top: 10px; - } - #tsd-search .results span.parent { - color: var(--color-text-aside); - font-weight: normal; - } - #tsd-search.has-focus { - background-color: var(--color-accent); - } - #tsd-search.has-focus .field input { - top: 0; - opacity: 1; - } - #tsd-search.has-focus .title, - #tsd-search.has-focus #tsd-toolbar-links a { - z-index: 0; - opacity: 0; - } - #tsd-search.has-focus .results { - visibility: visible; - } - #tsd-search.loading .results li.state.loading { - display: block; - } - #tsd-search.failure .results li.state.failure { - display: block; - } - - #tsd-toolbar-links { - position: absolute; - top: 0; - right: 2rem; - height: 100%; - display: flex; - align-items: center; - justify-content: flex-end; - } - #tsd-toolbar-links a { - margin-left: 1.5rem; - } - #tsd-toolbar-links a:hover { - text-decoration: underline; - } - - .tsd-signature { - margin: 0 0 1rem 0; - padding: 1rem 0.5rem; - border: 1px solid var(--color-accent); - font-family: Menlo, Monaco, Consolas, "Courier New", monospace; - font-size: 14px; - overflow-x: auto; - } - - .tsd-signature-keyword { - color: var(--color-ts-keyword); - font-weight: normal; - } - - .tsd-signature-symbol { - color: var(--color-text-aside); - font-weight: normal; - } - - .tsd-signature-type { - font-style: italic; - font-weight: normal; - } - - .tsd-signatures { - padding: 0; - margin: 0 0 1em 0; - list-style-type: none; - } - .tsd-signatures .tsd-signature { - margin: 0; - border-color: var(--color-accent); - border-width: 1px 0; - transition: background-color 0.1s; - } - .tsd-signatures .tsd-index-signature:not(:last-child) { - margin-bottom: 1em; - } - .tsd-signatures .tsd-index-signature .tsd-signature { - border-width: 1px; - } - .tsd-description .tsd-signatures .tsd-signature { - border-width: 1px; - } - - ul.tsd-parameter-list, - ul.tsd-type-parameter-list { - list-style: square; - margin: 0; - padding-left: 20px; - } - ul.tsd-parameter-list > li.tsd-parameter-signature, - ul.tsd-type-parameter-list > li.tsd-parameter-signature { - list-style: none; - margin-left: -20px; - } - ul.tsd-parameter-list h5, - ul.tsd-type-parameter-list h5 { - font-size: 16px; - margin: 1em 0 0.5em 0; - } - .tsd-sources { - margin-top: 1rem; - font-size: 0.875em; - } - .tsd-sources a { - color: var(--color-text-aside); - text-decoration: underline; - } - .tsd-sources ul { - list-style: none; - padding: 0; - } - - .tsd-page-toolbar { - position: sticky; - z-index: 1; - top: 0; - left: 0; - width: 100%; - color: var(--color-text); - background: var(--color-background-secondary); - border-bottom: 1px var(--color-accent) solid; - transition: transform 0.3s ease-in-out; - } - .tsd-page-toolbar a { - color: var(--color-text); - text-decoration: none; - } - .tsd-page-toolbar a.title { - font-weight: bold; - } - .tsd-page-toolbar a.title:hover { - text-decoration: underline; - } - .tsd-page-toolbar .tsd-toolbar-contents { - display: flex; - justify-content: space-between; - height: 2.5rem; - margin: 0 auto; - } - .tsd-page-toolbar .table-cell { - position: relative; - white-space: nowrap; - line-height: 40px; - } - .tsd-page-toolbar .table-cell:first-child { - width: 100%; - } - .tsd-page-toolbar .tsd-toolbar-icon { - box-sizing: border-box; - line-height: 0; - padding: 12px 0; - } - - .tsd-widget { - display: inline-block; - overflow: hidden; - opacity: 0.8; - height: 40px; - transition: - opacity 0.1s, - background-color 0.2s; - vertical-align: bottom; - cursor: pointer; - } - .tsd-widget:hover { - opacity: 0.9; - } - .tsd-widget.active { - opacity: 1; - background-color: var(--color-accent); - } - .tsd-widget.no-caption { - width: 40px; - } - .tsd-widget.no-caption:before { - margin: 0; - } - - .tsd-widget.options, - .tsd-widget.menu { - display: none; - } - input[type="checkbox"] + .tsd-widget:before { - background-position: -120px 0; - } - input[type="checkbox"]:checked + .tsd-widget:before { - background-position: -160px 0; - } - - img { - max-width: 100%; - } - - .tsd-member-summary-name { - display: inline-flex; - align-items: center; - padding: 0.25rem; - text-decoration: none; - } - - .tsd-anchor-icon { - display: inline-flex; - align-items: center; - margin-left: 0.5rem; - color: var(--color-text); - } - - .tsd-anchor-icon svg { - width: 1em; - height: 1em; - visibility: hidden; - } - - .tsd-member-summary-name:hover > .tsd-anchor-icon svg, - .tsd-anchor-link:hover > .tsd-anchor-icon svg { - visibility: visible; - } - - .deprecated { - text-decoration: line-through !important; - } - - .warning { - padding: 1rem; - color: var(--color-warning-text); - background: var(--color-background-warning); - } - - .tsd-kind-project { - color: var(--color-ts-project); - } - .tsd-kind-module { - color: var(--color-ts-module); - } - .tsd-kind-namespace { - color: var(--color-ts-namespace); - } - .tsd-kind-enum { - color: var(--color-ts-enum); - } - .tsd-kind-enum-member { - color: var(--color-ts-enum-member); - } - .tsd-kind-variable { - color: var(--color-ts-variable); - } - .tsd-kind-function { - color: var(--color-ts-function); - } - .tsd-kind-class { - color: var(--color-ts-class); - } - .tsd-kind-interface { - color: var(--color-ts-interface); - } - .tsd-kind-constructor { - color: var(--color-ts-constructor); - } - .tsd-kind-property { - color: var(--color-ts-property); - } - .tsd-kind-method { - color: var(--color-ts-method); - } - .tsd-kind-reference { - color: var(--color-ts-reference); - } - .tsd-kind-call-signature { - color: var(--color-ts-call-signature); - } - .tsd-kind-index-signature { - color: var(--color-ts-index-signature); - } - .tsd-kind-constructor-signature { - color: var(--color-ts-constructor-signature); - } - .tsd-kind-parameter { - color: var(--color-ts-parameter); - } - .tsd-kind-type-parameter { - color: var(--color-ts-type-parameter); - } - .tsd-kind-accessor { - color: var(--color-ts-accessor); - } - .tsd-kind-get-signature { - color: var(--color-ts-get-signature); - } - .tsd-kind-set-signature { - color: var(--color-ts-set-signature); - } - .tsd-kind-type-alias { - color: var(--color-ts-type-alias); - } - - /* if we have a kind icon, don't color the text by kind */ - .tsd-kind-icon ~ span { - color: var(--color-text); - } - - * { - scrollbar-width: thin; - scrollbar-color: var(--color-accent) var(--color-icon-background); - } - - *::-webkit-scrollbar { - width: 0.75rem; - } - - *::-webkit-scrollbar-track { - background: var(--color-icon-background); - } - - *::-webkit-scrollbar-thumb { - background-color: var(--color-accent); - border-radius: 999rem; - border: 0.25rem solid var(--color-icon-background); - } - - /* mobile */ - @media (max-width: 769px) { - .tsd-widget.options, - .tsd-widget.menu { - display: inline-block; - } - - .container-main { - display: flex; - } - html .col-content { - float: none; - max-width: 100%; - width: 100%; - } - html .col-sidebar { - position: fixed !important; - overflow-y: auto; - -webkit-overflow-scrolling: touch; - z-index: 1024; - top: 0 !important; - bottom: 0 !important; - left: auto !important; - right: 0 !important; - padding: 1.5rem 1.5rem 0 0; - width: 75vw; - visibility: hidden; - background-color: var(--color-background); - transform: translate(100%, 0); - } - html .col-sidebar > *:last-child { - padding-bottom: 20px; - } - html .overlay { - content: ""; - display: block; - position: fixed; - z-index: 1023; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.75); - visibility: hidden; - } - - .to-has-menu .overlay { - animation: fade-in 0.4s; - } - - .to-has-menu .col-sidebar { - animation: pop-in-from-right 0.4s; - } - - .from-has-menu .overlay { - animation: fade-out 0.4s; - } - - .from-has-menu .col-sidebar { - animation: pop-out-to-right 0.4s; - } - - .has-menu body { - overflow: hidden; - } - .has-menu .overlay { - visibility: visible; - } - .has-menu .col-sidebar { - visibility: visible; - transform: translate(0, 0); - display: flex; - flex-direction: column; - gap: 1.5rem; - max-height: 100vh; - padding: 1rem 2rem; - } - .has-menu .tsd-navigation { - max-height: 100%; - } - #tsd-toolbar-links { - display: none; - } - .tsd-navigation .tsd-nav-link { - display: flex; - } - } - - /* one sidebar */ - @media (min-width: 770px) { - .container-main { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); - grid-template-areas: "sidebar content"; - margin: 2rem auto; - } - - .col-sidebar { - grid-area: sidebar; - } - .col-content { - grid-area: content; - padding: 0 1rem; - } - } - @media (min-width: 770px) and (max-width: 1399px) { - .col-sidebar { - max-height: calc(100vh - 2rem - 42px); - overflow: auto; - position: sticky; - top: 42px; - padding-top: 1rem; - } - .site-menu { - margin-top: 1rem; - } - } - - /* two sidebars */ - @media (min-width: 1200px) { - .container-main { - grid-template-columns: minmax(0, 1fr) minmax(0, 2.5fr) minmax(0, 20rem); - grid-template-areas: "sidebar content toc"; - } - - .col-sidebar { - display: contents; - } - - .page-menu { - grid-area: toc; - padding-left: 1rem; - } - .site-menu { - grid-area: sidebar; - } - - .site-menu { - margin-top: 1rem; - } - - .page-menu, - .site-menu { - max-height: calc(100vh - 2rem - 42px); - overflow: auto; - position: sticky; - top: 42px; - } - } -} diff --git a/packages/binden/docs/classes/AcceptEncoding.html b/packages/binden/docs/classes/AcceptEncoding.html deleted file mode 100644 index 87fc1fc6..00000000 --- a/packages/binden/docs/classes/AcceptEncoding.html +++ /dev/null @@ -1,742 +0,0 @@ - - - - - - AcceptEncoding | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Class AcceptEncoding

-
-
-

- Hierarchy (View Summary) -

- -
-
-

Implements

- -
- -
-
-
- - - -
-
-

Constructors

- -
-
-

Accessors

- -
-
-

Methods

- -
-
-
-
-
-
- -

- - - - Constructors -

-
-
-
- - - -
-
-
-
- -

- - - - Accessors -

-
-
-
- - - -
-
- - - -
-
-
-
- -

- - - - Methods -

-
-
-
- - - -
-
- - - -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/classes/Authorization.html b/packages/binden/docs/classes/Authorization.html deleted file mode 100644 index 47e20466..00000000 --- a/packages/binden/docs/classes/Authorization.html +++ /dev/null @@ -1,796 +0,0 @@ - - - - - - Authorization | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Class Authorization

-
-
-

Implements

- -
- -
-
-
- - - -
-
-

Constructors

- -
-
-

Accessors

- -
-
-

Methods

- -
-
-
-
-
-
- -

- - - - Constructors -

-
-
-
- - - -
-
-
-
- -

- - - - Accessors -

-
-
-
- - - -
-
- - - -
-
-
-
- -

- - - - Methods -

-
-
-
- - -
    - -
  • -
    -

    Returns a string representation of an object.

    -
    -

    - Returns string -

    -
    - -
  • -
-
-
- - - -
-
- - - -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/classes/Binden.html b/packages/binden/docs/classes/Binden.html deleted file mode 100644 index 9f47799b..00000000 --- a/packages/binden/docs/classes/Binden.html +++ /dev/null @@ -1,1055 +0,0 @@ - - - - - - Binden | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Class Binden

-
- -
-
-
- - - -
-
-

Constructors

- -
-
-

Accessors

- -
-
-

Methods

- -
-
-
-
-
-
- -

- - - - Constructors -

-
-
-
- - - -
-
-
-
- -

- - - - Accessors -

-
-
-
- - - -
-
-
-
- -

- - - - Methods -

-
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - options: - Omit<
          ServerOptions<typeof - IncomingMessage, - typeof - ServerResponse>,
          "IncomingMessage" - | - "ServerResponse",
      > = {}
      -
    • -
    -
    -

    - Returns Server<typeof - BindenRequest, - typeof - BindenResponse> -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - options: - Omit<ServerOptions, - "IncomingMessage" - | - "ServerResponse"> = {} -
    • -
    -
    -

    - Returns Server<typeof - BindenRequest, - typeof - BindenResponse> -

    - -
  • -
-
-
- - - -
-
- - -
    - -
  • -
    -

    Parameters

    - -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - path: - string - | - RegExp -
    • -
    • - ...items: - IStackItem[] -
    • -
    -
    -

    - Returns this -

    - -
  • -
-
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/classes/BindenError.html b/packages/binden/docs/classes/BindenError.html deleted file mode 100644 index 9a1997ba..00000000 --- a/packages/binden/docs/classes/BindenError.html +++ /dev/null @@ -1,997 +0,0 @@ - - - - - - BindenError | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Class BindenError

-
-
-

Hierarchy

-
    -
  • - Error -
      -
    • - BindenError -
    • -
    -
  • -
-
- -
-
-
- - - -
-
-

Constructors

- -
-
-

Properties

- -
-
-

Accessors

- -
-
-

Methods

- -
-
-
-
-
-
- -

- - - - Constructors -

-
-
-
- - - -
-
-
-
- -

- - - - Properties -

-
-
-
- - -
- cause?: - unknown -
- -
-
- - -
- message: - string -
- -
-
- - -
- name: - string -
- -
-
- - -
- stack?: - string -
- -
-
- - -
- prepareStackTrace?: - (err: - Error, - stackTraces: - CallSite[]) - => - any -
-
-

Optional override for formatting stack traces

-
- - -
-
- - -
- stackTraceLimit: - number -
- -
-
-
-
- -

- - - - Accessors -

-
-
-
- - -
    -
  • - get - expose(): - boolean -
  • -
  • -

    - Returns boolean -

    - -
  • -
-
-
- - -
    -
  • - get - json(): - null - | - Record<string, - unknown> -
  • -
  • -

    - Returns null - | - Record<string, - unknown> -

    - -
  • -
-
-
- - -
    -
  • - get - status(): - number -
  • -
  • -

    - Returns number -

    - -
  • -
-
-
-
-
- -

- - - - Methods -

-
-
-
- - -
    - -
  • -
    -

    Create .stack property on a target object

    -
    -
    -

    Parameters

    -
      -
    • - targetObject: - object -
    • -
    • - OptionalconstructorOpt: - Function -
    • -
    -
    -

    - Returns void -

    -
    - -
  • -
-
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/classes/BindenRequest.html b/packages/binden/docs/classes/BindenRequest.html deleted file mode 100644 index f92c47cf..00000000 --- a/packages/binden/docs/classes/BindenRequest.html +++ /dev/null @@ -1,14562 +0,0 @@ - - - - - - BindenRequest | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Class BindenRequest

-
-
-

Hierarchy

-
    -
  • - IncomingMessage -
      -
    • - BindenRequest -
    • -
    -
  • -
-
- -
-
-
- - - -
-
-

Constructors

- -
-
-

Properties

- -
-
-

Accessors

- -
-
-

Methods

- -
-
-
-
-
-
- -

- - - - Constructors -

-
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - socket: - Socket -
    • -
    -
    -

    - Returns - BindenRequest -

    - -
  • -
-
-
-
-
- -

- - - - Properties -

-
-
-
- - -
- aborted: - boolean -
-
-

- The message.aborted property will be - true if the request has been aborted. -

-
-
-
- -

v10.1.0

-
-
- -

- Since v17.0.0,v16.12.0 - Check - message.destroyed from - stream.Readable. -

-
-
- -
-
- - -
- closed: - boolean -
-
-

- Is true after 'close' has been - emitted. -

-
-
-
- -

v18.0.0

-
-
- -
-
- - -
- complete: - boolean -
-
-

- The message.complete property will be - true if a complete HTTP message has been received - and successfully parsed. -

-

- This property is particularly useful as a means of determining - if a client or server fully transmitted a message before a - connection was terminated: -

-
const req = http.request({
host: '127.0.0.1',
port: 8080,
method: 'POST',
}, (res) => {
res.resume();
res.on('end', () => {
if (!res.complete)
console.error(
'The connection was terminated while the message was still being sent');
});
}); -
-
-
-
- -

v0.3.0

-
-
- -
-
- - -
- connection: - Socket -
-
-

Alias for message.socket.

-
-
-
- -

v0.1.90

-
-
- -

Since v16.0.0 - Use socket.

-
-
- -
-
- - -
- destroyed: - boolean -
-
-

- Is true after readable.destroy() has - been called. -

-
-
-
- -

v8.0.0

-
-
- -
-
- - -
- errored: - null - | - Error -
-
-

- Returns error if the stream has been destroyed with an error. -

-
-
-
- -

v18.0.0

-
-
- -
-
- - -
- headers: - IncomingHttpHeaders -
-
-

The request/response headers object.

-

- Key-value pairs of header names and values. Header names are - lower-cased. -

-
// Prints something like:
//
// { 'user-agent': 'curl/7.22.0',
// host: '127.0.0.1:8000',
// accept: '*' }
console.log(request.headers); -
- -

- Duplicates in raw headers are handled in the following ways, - depending on the header name: -

-
    -
  • - Duplicates of age, authorization, - content-length, content-type, - etag, expires, from, - host, if-modified-since, - if-unmodified-since, - last-modified, location, - max-forwards, proxy-authorization, - referer, retry-after, - server, or user-agent are - discarded. To allow duplicate values of the headers listed - above to be joined, use the option - joinDuplicateHeaders in - request and - createServer. See RFC 9110 Section - 5.3 for more information. -
  • -
  • - set-cookie is always an array. Duplicates are - added to the array. -
  • -
  • - For duplicate cookie headers, the values are - joined together with ; . -
  • -
  • - For all other headers, the values are joined together with - , . -
  • -
-
-
-
- -

v0.1.5

-
-
- -
-
- - -
- headersDistinct: - Dict<string[]> -
-
-

- Similar to message.headers, but there is no join - logic and the values are always arrays of strings, even for - headers received just once. -

-
// Prints something like:
//
// { 'user-agent': ['curl/7.22.0'],
// host: ['127.0.0.1:8000'],
// accept: ['*'] }
console.log(request.headersDistinct); -
-
-
-
- -

v18.3.0, v16.17.0

-
-
- -
-
- - -
- httpVersion: - string -
-
-

- In case of server request, the HTTP version sent by the - client. In the case of client response, the HTTP version of - the connected-to server. Probably either '1.1' or - '1.0'. -

-

- Also message.httpVersionMajor is the first - integer and message.httpVersionMinor is the - second. -

-
-
-
- -

v0.1.1

-
-
- -
-
- - -
- httpVersionMajor: - number -
- -
-
- - -
- httpVersionMinor: - number -
- -
-
- - -
- method?: - string -
-
-

- Only valid for request obtained from - Server. -

-

- The request method as a string. Read only. Examples: - 'GET', 'DELETE'. -

-
-
-
- -

v0.1.1

-
-
- -
-
- - -
- rawHeaders: - string[] -
-
-

- The raw request/response headers list exactly as they were - received. -

-

- The keys and values are in the same list. It is not a - list of tuples. So, the even-numbered offsets are key values, - and the odd-numbered offsets are the associated values. -

-

- Header names are not lowercased, and duplicates are not - merged. -

-
// Prints something like:
//
// [ 'user-agent',
// 'this is invalid because there can be only one',
// 'User-Agent',
// 'curl/7.22.0',
// 'Host',
// '127.0.0.1:8000',
// 'ACCEPT',
// '*' ]
console.log(request.rawHeaders); -
-
-
-
- -

v0.11.6

-
-
- -
-
- - -
- rawTrailers: - string[] -
-
-

- The raw request/response trailer keys and values exactly as - they were received. Only populated at the - 'end' event. -

-
-
-
- -

v0.11.6

-
-
- -
-
- - -
- readable: - boolean -
-
-

- Is true if it is safe to call - read, which means the stream has not been destroyed or emitted - 'error' or 'end'. -

-
-
-
- -

v11.4.0

-
-
- -
-
- - -
- readableAborted: - boolean -
-
-

- Returns whether the stream was destroyed or errored before - emitting 'end'. -

-
-
-
- -

v16.8.0

-
-
- -
-
- - -
- readableDidRead: - boolean -
-
-

Returns whether 'data' has been emitted.

-
-
-
- -

v16.7.0, v14.18.0

-
-
- -
-
- - -
- readableEncoding: - null - | - BufferEncoding -
-
-

- Getter for the property encoding of a given - Readable stream. The - encoding property can be set using the - setEncoding - method. -

-
-
-
- -

v12.7.0

-
-
- -
-
- - -
- readableEnded: - boolean -
-
-

- Becomes true when - 'end' - event is emitted. -

-
-
-
- -

v12.9.0

-
-
- -
-
- - -
- readableFlowing: - null - | - boolean -
-
-

- This property reflects the current state of a - Readable stream as described in the - Three states - section. -

-
-
-
- -

v9.4.0

-
-
- -
-
- - -
- readableHighWaterMark: - number -
-
-

- Returns the value of highWaterMark passed when - creating this Readable. -

-
-
-
- -

v9.3.0

-
-
- -
-
- - -
- readableLength: - number -
-
-

- This property contains the number of bytes (or objects) in the - queue ready to be read. The value provides introspection data - regarding the status of the highWaterMark. -

-
-
-
- -

v9.4.0

-
-
- -
-
- - -
- readableObjectMode: - boolean -
-
-

- Getter for the property objectMode of a given - Readable stream. -

-
-
-
- -

v12.3.0

-
-
- -
-
- - -
- socket: - Socket -
-
-

- The net.Socket object associated with the - connection. -

-

- With HTTPS support, use - request.socket.getPeerCertificate() to obtain the - client's authentication details. -

-

- This property is guaranteed to be an instance of the - net.Socket class, a subclass of - stream.Duplex, unless the user specified a socket - type other than net.Socket or internally nulled. -

-
-
-
- -

v0.3.0

-
-
- -
-
- - -
- statusCode?: - number -
-
-

- Only valid for response obtained from - ClientRequest. -

-

- The 3-digit HTTP response status code. E.G. 404. -

-
-
-
- -

v0.1.1

-
-
- -
-
- - -
- statusMessage?: - string -
-
-

- Only valid for response obtained from - ClientRequest. -

-

- The HTTP response status message (reason phrase). E.G. - OK or Internal Server Error. -

-
-
-
- -

v0.11.10

-
-
- -
-
- - -
- trailers: - Dict<string> -
-
-

- The request/response trailers object. Only populated at the - 'end' event. -

-
-
-
- -

v0.3.0

-
-
- -
-
- - -
- trailersDistinct: - Dict<string[]> -
-
-

- Similar to message.trailers, but there is no join - logic and the values are always arrays of strings, even for - headers received just once. Only populated at the - 'end' event. -

-
-
-
- -

v18.3.0, v16.17.0

-
-
- -
-
- - -
- url?: - string -
-
-

- Only valid for request obtained from - Server. -

-

- Request URL string. This contains only the URL that is present - in the actual HTTP request. Take the following request: -

-
GET /status?name=ryan HTTP/1.1
-Accept: text/plain
-
- -

To parse the URL into its parts:

-
new URL(`http://${process.env.HOST ?? 'localhost'}${request.url}`);
-
- -

- When request.url is - '/status?name=ryan' and - process.env.HOST is undefined: -

-
$ node
> new URL(`http://${process.env.HOST ?? 'localhost'}${request.url}`);
URL {
href: 'http://localhost/status?name=ryan',
origin: 'http://localhost',
protocol: 'http:',
username: '',
password: '',
host: 'localhost',
hostname: 'localhost',
port: '',
pathname: '/status',
search: '?name=ryan',
searchParams: URLSearchParams { 'name' => 'ryan' },
hash: ''
} -
- -

- Ensure that you set process.env.HOST to the - server's host name, or consider replacing this part entirely. - If using req.headers.host, ensure proper - validation is used, as clients may specify a custom - Host header. -

-
-
-
- -

v0.1.90

-
-
- -
-
- - -
- captureRejections: - boolean -
-
-

- Value: - boolean -

-

- Change the default captureRejections option on - all new EventEmitter objects. -

-
-
-
- -

v13.4.0, v12.16.0

-
-
- -
-
- - -
- captureRejectionSymbol: - typeof - captureRejectionSymbol -
-
-

Value: Symbol.for('nodejs.rejection')

-

See how to write a custom rejection handler.

-
-
-
- -

v13.4.0, v12.16.0

-
-
- -
-
- - -
- defaultMaxListeners: - number -
-
-

- By default, a maximum of 10 listeners can be - registered for any single event. This limit can be changed for - individual EventEmitter instances using the - emitter.setMaxListeners(n) method. To change the - default for allEventEmitter instances, - the events.defaultMaxListeners property can be - used. If this value is not a positive number, a - RangeError is thrown. -

-

- Take caution when setting the - events.defaultMaxListeners because the change - affects all EventEmitter instances, - including those created before the change is made. However, - calling emitter.setMaxListeners(n) still has - precedence over events.defaultMaxListeners. -

-

- This is not a hard limit. The - EventEmitter instance will allow more listeners - to be added but will output a trace warning to stderr - indicating that a "possible EventEmitter memory - leak" has been detected. For any single - EventEmitter, the - emitter.getMaxListeners() and - emitter.setMaxListeners() methods can be used to - temporarily avoid this warning: -

-
import { EventEmitter } from 'node:events';
const emitter = new EventEmitter();
emitter.setMaxListeners(emitter.getMaxListeners() + 1);
emitter.once('event', () => {
// do stuff
emitter.setMaxListeners(Math.max(emitter.getMaxListeners() - 1, 0));
}); -
- -

- The --trace-warnings command-line flag can be - used to display the stack trace for such warnings. -

-

- The emitted warning can be inspected with - process.on('warning') and will have the - additional emitter, type, and - count properties, referring to the event emitter - instance, the event's name and the number of attached - listeners, respectively. Its name property is set - to 'MaxListenersExceededWarning'. -

-
-
-
- -

v0.11.2

-
-
- -
-
- - -
- errorMonitor: - typeof - errorMonitor -
-
-

- This symbol shall be used to install a listener for only - monitoring 'error' events. Listeners installed - using this symbol are called before the regular - 'error' listeners are called. -

-

- Installing a listener using this symbol does not change the - behavior once an 'error' event is emitted. - Therefore, the process will still crash if no regular - 'error' listener is installed. -

-
-
-
- -

v13.6.0, v12.17.0

-
-
- -
-
-
-
- -

- - - - Accessors -

-
-
-
- - - -
-
- - - -
-
- - -
    -
  • - get - body(): - unknown -
  • -
  • -

    - Returns unknown -

    - -
  • -
  • - set - body(body: - unknown): - void -
  • -
  • -
    -

    Parameters

    -
      -
    • - body: - unknown -
    • -
    -
    -

    - Returns void -

    - -
  • -
-
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - -
    -
  • - get - id(): - string -
  • -
  • -

    - Returns string -

    - -
  • -
-
-
- - - -
-
- - - -
-
- - -
    -
  • - get - query(): - ParsedUrlQuery -
  • -
  • -

    - Returns - ParsedUrlQuery -

    - -
  • -
-
-
- - - -
-
- - -
    -
  • - get - secure(): - boolean -
  • -
  • -

    - Returns boolean -

    - -
  • -
-
-
- - -
    -
  • - get - URL(): - URL -
  • -
  • -

    - Returns URL -

    - -
  • -
-
-
-
-
- -

- - - - Methods -

-
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - callback: - (error?: - null - | - Error) - => - void -
    • -
    -
    -

    - Returns void -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - error: - null - | - Error -
    • -
    • - callback: - (error?: - null - | - Error) - => - void -
    • -
    -
    -

    - Returns void -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - size: - number -
    • -
    -
    -

    - Returns void -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Calls readable.destroy() with an - AbortError and returns a promise that - fulfills when the stream is finished. -

    -
    -

    - Returns Promise<void> -

    -
    -
    - -

    v20.4.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -

    - Returns - AsyncIterableIterator<any> -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    Type Parameters

    -
      -
    • - K -
    • -
    -
    -
    -

    Parameters

    -
      -
    • - error: - Error -
    • -
    • - event: - string - | - symbol -
    • -
    • - ...args: - AnyRest -
    • -
    -
    -

    - Returns void -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Event emitter The defined events on documents including: -

    -
      -
    1. close
    2. -
    3. data
    4. -
    5. end
    6. -
    7. error
    8. -
    9. pause
    10. -
    11. readable
    12. -
    13. resume
    14. -
    -
    -
    -

    Parameters

    -
      -
    • - event: - "close" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    -
    - -
  • - -
  • -
    -

    - Event emitter The defined events on documents including: -

    -
      -
    1. close
    2. -
    3. data
    4. -
    5. end
    6. -
    7. error
    8. -
    9. pause
    10. -
    11. readable
    12. -
    13. resume
    14. -
    -
    -
    -

    Parameters

    -
      -
    • - event: - "data" -
    • -
    • - listener: - (chunk: - any) - => - void -
    • -
    -
    -

    - Returns this -

    -
    - -
  • - -
  • -
    -

    - Event emitter The defined events on documents including: -

    -
      -
    1. close
    2. -
    3. data
    4. -
    5. end
    6. -
    7. error
    8. -
    9. pause
    10. -
    11. readable
    12. -
    13. resume
    14. -
    -
    -
    -

    Parameters

    -
      -
    • - event: - "end" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    -
    - -
  • - -
  • -
    -

    - Event emitter The defined events on documents including: -

    -
      -
    1. close
    2. -
    3. data
    4. -
    5. end
    6. -
    7. error
    8. -
    9. pause
    10. -
    11. readable
    12. -
    13. resume
    14. -
    -
    -
    -

    Parameters

    -
      -
    • - event: - "error" -
    • -
    • - listener: - (err: - Error) - => - void -
    • -
    -
    -

    - Returns this -

    -
    - -
  • - -
  • -
    -

    - Event emitter The defined events on documents including: -

    -
      -
    1. close
    2. -
    3. data
    4. -
    5. end
    6. -
    7. error
    8. -
    9. pause
    10. -
    11. readable
    12. -
    13. resume
    14. -
    -
    -
    -

    Parameters

    -
      -
    • - event: - "pause" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    -
    - -
  • - -
  • -
    -

    - Event emitter The defined events on documents including: -

    -
      -
    1. close
    2. -
    3. data
    4. -
    5. end
    6. -
    7. error
    8. -
    9. pause
    10. -
    11. readable
    12. -
    13. resume
    14. -
    -
    -
    -

    Parameters

    -
      -
    • - event: - "readable" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    -
    - -
  • - -
  • -
    -

    - Event emitter The defined events on documents including: -

    -
      -
    1. close
    2. -
    3. data
    4. -
    5. end
    6. -
    7. error
    8. -
    9. pause
    10. -
    11. readable
    12. -
    13. resume
    14. -
    -
    -
    -

    Parameters

    -
      -
    • - event: - "resume" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    -
    - -
  • - -
  • -
    -

    - Event emitter The defined events on documents including: -

    -
      -
    1. close
    2. -
    3. data
    4. -
    5. end
    6. -
    7. error
    8. -
    9. pause
    10. -
    11. readable
    12. -
    13. resume
    14. -
    -
    -
    -

    Parameters

    -
      -
    • - event: - string - | - symbol -
    • -
    • - listener: - (...args: - any[]) - => - void -
    • -
    -
    -

    - Returns this -

    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - This method returns a new stream with chunks of the - underlying stream paired with a counter in the form - [index, chunk]. The first index value is - 0 and it increases by 1 for each chunk - produced. -

    -
    -
    -

    Parameters

    -
      -
    • - Optionaloptions: - Pick<ArrayOptions, - "signal"> -
    • -
    -
    -

    - Returns Readable -

    -

    a stream of indexed pairs.

    -
    -
    - -

    v17.5.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    Type Parameters

    -
      -
    • - T extends ReadableStream -
    • -
    -
    -
    -

    Parameters

    -
      -
    • - stream: - ComposeFnParam - | - T - | - Iterable<T> - | - AsyncIterable<T> -
    • -
    • - Optionaloptions: - { - signal: - AbortSignal - } -
    • -
    -
    -

    - Returns - T -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Calls destroy() on the socket that received - the IncomingMessage. If error is - provided, an 'error' event is emitted on the - socket and error is passed as an argument to - any listeners on the event. -

    -
    -
    -

    Parameters

    -
      -
    • - Optionalerror: - Error -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.3.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - This method returns a new stream with the first - limit chunks dropped from the start. -

    -
    -
    -

    Parameters

    -
      -
    • - limit: - number -
      -

      the number of chunks to drop from the readable.

      -
      -
      -
    • -
    • - Optionaloptions: - Pick<ArrayOptions, - "signal"> -
    • -
    -
    -

    - Returns Readable -

    -

    - a stream with limit chunks dropped from the start. -

    -
    -
    - -

    v17.5.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Synchronously calls each of the listeners registered for - the event named eventName, in the order they - were registered, passing the supplied arguments to each. -

    -

    - Returns true if the event had listeners, - false otherwise. -

    -
    import { EventEmitter } from 'node:events';
    const myEmitter = new EventEmitter();

    // First listener
    myEmitter.on('event', function firstListener() {
    console.log('Helloooo! first listener');
    });
    // Second listener
    myEmitter.on('event', function secondListener(arg1, arg2) {
    console.log(`event with parameters ${arg1}, ${arg2} in second listener`);
    });
    // Third listener
    myEmitter.on('event', function thirdListener(...args) {
    const parameters = args.join(', ');
    console.log(`event with parameters ${parameters} in third listener`);
    });

    console.log(myEmitter.listeners('event'));

    myEmitter.emit('event', 1, 2, 3, 4, 5);

    // Prints:
    // [
    // [Function: firstListener],
    // [Function: secondListener],
    // [Function: thirdListener]
    // ]
    // Helloooo! first listener
    // event with parameters 1, 2 in second listener
    // event with parameters 1, 2, 3, 4, 5 in third listener -
    -
    -
    -

    Parameters

    -
      -
    • - event: - "close" -
    • -
    -
    -

    - Returns boolean -

    -
    -
    - -

    v0.1.26

    -
    -
    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "data" -
    • -
    • - chunk: - any -
    • -
    -
    -

    - Returns boolean -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "end" -
    • -
    -
    -

    - Returns boolean -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "error" -
    • -
    • - err: - Error -
    • -
    -
    -

    - Returns boolean -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "pause" -
    • -
    -
    -

    - Returns boolean -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "readable" -
    • -
    -
    -

    - Returns boolean -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "resume" -
    • -
    -
    -

    - Returns boolean -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - string - | - symbol -
    • -
    • - ...args: - any[] -
    • -
    -
    -

    - Returns boolean -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Returns an array listing the events for which the emitter - has registered listeners. The values in the array are - strings or Symbols. -

    -
    import { EventEmitter } from 'node:events';

    const myEE = new EventEmitter();
    myEE.on('foo', () => {});
    myEE.on('bar', () => {});

    const sym = Symbol('symbol');
    myEE.on(sym, () => {});

    console.log(myEE.eventNames());
    // Prints: [ 'foo', 'bar', Symbol(symbol) ] -
    -
    -

    - Returns (string - | - symbol)[] -

    -
    -
    - -

    v6.0.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - This method is similar to - Array.prototype.every and calls - fn on each chunk in the stream to check if all - awaited return values are truthy value for fn. - Once an fn call on a chunk awaited - return value is falsy, the stream is destroyed and the - promise is fulfilled with false. If all of - the fn calls on the chunks return a truthy value, - the promise is fulfilled with true. -

    -
    -
    -

    Parameters

    -
      -
    • - fn: - (
          data: - any,
          options?: - Pick<ArrayOptions, - "signal">,
      ) - => - boolean - | - Promise<boolean>
      -
      -

      - a function to call on each chunk of the stream. - Async or not. -

      -
      -
      -
    • -
    • - Optionaloptions: - ArrayOptions -
    • -
    -
    -

    - Returns Promise<boolean> -

    -

    - a promise evaluating to true if - fn returned a truthy value for every one of the - chunks. -

    -
    -
    - -

    v17.5.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - This method allows filtering the stream. For each chunk in - the stream the fn function will be called and if - it returns a truthy value, the chunk will be passed to the - result stream. If the fn function returns a - promise - that promise will be awaited. -

    -
    -
    -

    Parameters

    -
      -
    • - fn: - (
          data: - any,
          options?: - Pick<ArrayOptions, - "signal">,
      ) - => - boolean - | - Promise<boolean>
      -
      -

      - a function to filter chunks from the stream. Async - or not. -

      -
      -
      -
    • -
    • - Optionaloptions: - ArrayOptions -
    • -
    -
    -

    - Returns Readable -

    -

    a stream filtered with the predicate fn.

    -
    -
    - -

    v17.4.0, v16.14.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - This method is similar to - Array.prototype.find and calls fn on - each chunk in the stream to find a chunk with a truthy - value for fn. Once an fn call's awaited - return value is truthy, the stream is destroyed and the - promise is fulfilled with value for which - fn returned a truthy value. If all of the - fn calls on the chunks return a falsy value, the - promise is fulfilled with undefined. -

    -
    -
    -

    Type Parameters

    -
      -
    • - T -
    • -
    -
    -
    -

    Parameters

    -
      -
    • - fn: - (data: - any, - options?: - Pick<ArrayOptions, - "signal">) - => - data - is - T -
      -

      - a function to call on each chunk of the stream. - Async or not. -

      -
      -
      -
    • -
    • - Optionaloptions: - ArrayOptions -
    • -
    -
    -

    - Returns Promise<undefined - | - T> -

    -

    - a promise evaluating to the first chunk for which - fn evaluated with a truthy value, or - undefined if no element was found. -

    -
    -
    - -

    v17.5.0

    -
    -
    - -
  • - -
  • -
    -

    - This method is similar to - Array.prototype.find and calls fn on - each chunk in the stream to find a chunk with a truthy - value for fn. Once an fn call's awaited - return value is truthy, the stream is destroyed and the - promise is fulfilled with value for which - fn returned a truthy value. If all of the - fn calls on the chunks return a falsy value, the - promise is fulfilled with undefined. -

    -
    -
    -

    Parameters

    -
      -
    • - fn: - (
          data: - any,
          options?: - Pick<ArrayOptions, - "signal">,
      ) - => - boolean - | - Promise<boolean>
      -
      -

      - a function to call on each chunk of the stream. - Async or not. -

      -
      -
      -
    • -
    • - Optionaloptions: - ArrayOptions -
    • -
    -
    -

    - Returns Promise<any> -

    -

    - a promise evaluating to the first chunk for which - fn evaluated with a truthy value, or - undefined if no element was found. -

    -
    -
    - -

    v17.5.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - This method returns a new stream by applying the given - callback to each chunk of the stream and then flattening - the result. -

    -

    - It is possible to return a stream or another iterable or - async iterable from fn and the result streams - will be merged (flattened) into the returned stream. -

    -
    -
    -

    Parameters

    -
      -
    • - fn: - (data: - any, - options?: - Pick<ArrayOptions, - "signal">) - => - any -
      -

      - a function to map over every chunk in the stream. - May be async. May be a stream or generator. -

      -
      -
      -
    • -
    • - Optionaloptions: - ArrayOptions -
    • -
    -
    -

    - Returns Readable -

    -

    a stream flat-mapped with the function fn.

    -
    -
    - -

    v17.5.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - This method allows iterating a stream. For each chunk in - the stream the fn function will be called. If the - fn function returns a promise - that promise will - be awaited. -

    -

    - This method is different from - for await...of loops in that it can - optionally process chunks concurrently. In addition, a - forEach iteration can only be stopped by - having passed a signal option and aborting - the related AbortController while - for await...of can be stopped with - break or return. In either case - the stream will be destroyed. -

    -

    - This method is different from listening to the - 'data' event in that it uses the - readable event in the underlying machinary - and can limit the number of concurrent fn calls. -

    -
    -
    -

    Parameters

    -
      -
    • - fn: - (data: - any, - options?: - Pick<ArrayOptions, - "signal">) - => - void - | - Promise<void> -
      -

      - a function to call on each chunk of the stream. - Async or not. -

      -
      -
      -
    • -
    • - Optionaloptions: - ArrayOptions -
    • -
    -
    -

    - Returns Promise<void> -

    -

    a promise for when the stream has finished.

    -
    -
    - -

    v17.5.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Returns the current max listener value for the - EventEmitter which is either set by - emitter.setMaxListeners(n) or defaults to - defaultMaxListeners. -

    -
    -

    - Returns number -

    -
    -
    - -

    v1.0.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - name: - string -
    • -
    -
    -

    - Returns undefined - | - string - | - string[] -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    - The readable.isPaused() method returns the - current operating state of the Readable. This - is used primarily by the mechanism that underlies the - readable.pipe() method. In most typical - cases, there will be no reason to use this method - directly. -

    -
    const readable = new stream.Readable();

    readable.isPaused(); // === false
    readable.pause();
    readable.isPaused(); // === true
    readable.resume();
    readable.isPaused(); // === false -
    -
    -

    - Returns boolean -

    -
    -
    - -

    v0.11.14

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - The iterator created by this method gives users the option - to cancel the destruction of the stream if the - for await...of loop is exited by - return, break, or - throw, or if the iterator should destroy the - stream if the stream emitted an error during iteration. -

    -
    -
    -

    Parameters

    -
      -
    • - Optionaloptions: - { - destroyOnReturn?: - boolean - } -
        -
      • -
        - OptionaldestroyOnReturn?: boolean -
        -
        -

        - When set to false, calling - return on the async iterator, or - exiting a for await...of iteration - using a break, return, - or throw will not destroy the - stream. - Default: true. -

        -
        -
        -
      • -
      -
    • -
    -
    -

    - Returns - AsyncIterableIterator<any> -

    -
    -
    - -

    v16.3.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Returns the number of listeners listening for the event - named eventName. If listener is - provided, it will return how many times the listener is - found in the list of the listeners of the event. -

    -
    -
    -

    Type Parameters

    -
      -
    • - K -
    • -
    -
    -
    -

    Parameters

    -
      -
    • - eventName: - string - | - symbol -
      -

      The name of the event being listened for

      -
      -
      -
    • -
    • - Optionallistener: - Function -
      -

      The event handler function

      -
      -
      -
    • -
    -
    -

    - Returns number -

    -
    -
    - -

    v3.2.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Returns a copy of the array of listeners for the event - named eventName. -

    -
    server.on('connection', (stream) => {
    console.log('someone connected!');
    });
    console.log(util.inspect(server.listeners('connection')));
    // Prints: [ [Function] ] -
    -
    -
    -

    Type Parameters

    -
      -
    • - K -
    • -
    -
    -
    -

    Parameters

    -
      -
    • - eventName: - string - | - symbol -
    • -
    -
    -

    - Returns Function[] -

    -
    -
    - -

    v0.1.26

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - This method allows mapping over the stream. The - fn function will be called for every chunk in the - stream. If the fn function returns a promise - - that promise will be awaited before being - passed to the result stream. -

    -
    -
    -

    Parameters

    -
      -
    • - fn: - (data: - any, - options?: - Pick<ArrayOptions, - "signal">) - => - any -
      -

      - a function to map over every chunk in the stream. - Async or not. -

      -
      -
      -
    • -
    • - Optionaloptions: - ArrayOptions -
    • -
    -
    -

    - Returns Readable -

    -

    a stream mapped with the function fn.

    -
    -
    - -

    v17.4.0, v16.14.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    Alias for emitter.removeListener().

    -
    -
    -

    Type Parameters

    -
      -
    • - K -
    • -
    -
    -
    -

    Parameters

    -
      -
    • - eventName: - string - | - symbol -
    • -
    • - listener: - (...args: - any[]) - => - void -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v10.0.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Adds the listener function to the end of the - listeners array for the event named - eventName. No checks are made to see if the - listener has already been added. Multiple - calls passing the same combination of - eventName and listener will - result in the listener being added, and - called, multiple times. -

    -
    server.on('connection', (stream) => {
    console.log('someone connected!');
    }); -
    - -

    - Returns a reference to the EventEmitter, so - that calls can be chained. -

    -

    - By default, event listeners are invoked in the order they - are added. The - emitter.prependListener() method can be used - as an alternative to add the event listener to the - beginning of the listeners array. -

    -
    import { EventEmitter } from 'node:events';
    const myEE = new EventEmitter();
    myEE.on('foo', () => console.log('a'));
    myEE.prependListener('foo', () => console.log('b'));
    myEE.emit('foo');
    // Prints:
    // b
    // a -
    -
    -
    -

    Parameters

    -
      -
    • - event: - "close" -
    • -
    • - listener: - () - => - void -
      -

      The callback function

      -
      -
      -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.1.101

    -
    -
    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "data" -
    • -
    • - listener: - (chunk: - any) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "end" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "error" -
    • -
    • - listener: - (err: - Error) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "pause" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "readable" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "resume" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - string - | - symbol -
    • -
    • - listener: - (...args: - any[]) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Adds a one-time - listener function for the event named - eventName. The next time - eventName is triggered, this listener is - removed and then invoked. -

    -
    server.once('connection', (stream) => {
    console.log('Ah, we have our first user!');
    }); -
    - -

    - Returns a reference to the EventEmitter, so - that calls can be chained. -

    -

    - By default, event listeners are invoked in the order they - are added. The - emitter.prependOnceListener() method can be - used as an alternative to add the event listener to the - beginning of the listeners array. -

    -
    import { EventEmitter } from 'node:events';
    const myEE = new EventEmitter();
    myEE.once('foo', () => console.log('a'));
    myEE.prependOnceListener('foo', () => console.log('b'));
    myEE.emit('foo');
    // Prints:
    // b
    // a -
    -
    -
    -

    Parameters

    -
      -
    • - event: - "close" -
    • -
    • - listener: - () - => - void -
      -

      The callback function

      -
      -
      -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.3.0

    -
    -
    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "data" -
    • -
    • - listener: - (chunk: - any) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "end" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "error" -
    • -
    • - listener: - (err: - Error) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "pause" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "readable" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "resume" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - string - | - symbol -
    • -
    • - listener: - (...args: - any[]) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    - The readable.pause() method will cause a - stream in flowing mode to stop emitting - 'data' events, switching out of flowing mode. - Any data that becomes available will remain in the - internal buffer. -

    -
    const readable = getReadableStreamSomehow();
    readable.on('data', (chunk) => {
    console.log(`Received ${chunk.length} bytes of data.`);
    readable.pause();
    console.log('There will be no additional data for 1 second.');
    setTimeout(() => {
    console.log('Now data will start flowing again.');
    readable.resume();
    }, 1000);
    }); -
    - -

    - The readable.pause() method has no effect if - there is a 'readable' event listener. -

    -
    -

    - Returns this -

    -
    -
    - -

    v0.9.4

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    Type Parameters

    -
      -
    • - T extends WritableStream -
    • -
    -
    -
    -

    Parameters

    -
      -
    • - destination: - T -
    • -
    • - Optionaloptions: - { - end?: - boolean - } -
    • -
    -
    -

    - Returns - T -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Adds the listener function to the - beginning of the listeners array for the event - named eventName. No checks are made to see if - the listener has already been added. Multiple - calls passing the same combination of - eventName and listener will - result in the listener being added, and - called, multiple times. -

    -
    server.prependListener('connection', (stream) => {
    console.log('someone connected!');
    }); -
    - -

    - Returns a reference to the EventEmitter, so - that calls can be chained. -

    -
    -
    -

    Parameters

    -
      -
    • - event: - "close" -
    • -
    • - listener: - () - => - void -
      -

      The callback function

      -
      -
      -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v6.0.0

    -
    -
    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "data" -
    • -
    • - listener: - (chunk: - any) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "end" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "error" -
    • -
    • - listener: - (err: - Error) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "pause" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "readable" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "resume" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - string - | - symbol -
    • -
    • - listener: - (...args: - any[]) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Adds a one-timelistener function for the event named - eventName to the beginning of the - listeners array. The next time eventName is - triggered, this listener is removed, and then invoked. -

    -
    server.prependOnceListener('connection', (stream) => {
    console.log('Ah, we have our first user!');
    }); -
    - -

    - Returns a reference to the EventEmitter, so - that calls can be chained. -

    -
    -
    -

    Parameters

    -
      -
    • - event: - "close" -
    • -
    • - listener: - () - => - void -
      -

      The callback function

      -
      -
      -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v6.0.0

    -
    -
    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "data" -
    • -
    • - listener: - (chunk: - any) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "end" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "error" -
    • -
    • - listener: - (err: - Error) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "pause" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "readable" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "resume" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - string - | - symbol -
    • -
    • - listener: - (...args: - any[]) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - chunk: - any -
    • -
    • - Optionalencoding: - BufferEncoding -
    • -
    -
    -

    - Returns boolean -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Returns a copy of the array of listeners for the event - named eventName, including any wrappers (such - as those created by .once()). -

    -
    import { EventEmitter } from 'node:events';
    const emitter = new EventEmitter();
    emitter.once('log', () => console.log('log once'));

    // Returns a new Array with a function `onceWrapper` which has a property
    // `listener` which contains the original listener bound above
    const listeners = emitter.rawListeners('log');
    const logFnWrapper = listeners[0];

    // Logs "log once" to the console and does not unbind the `once` event
    logFnWrapper.listener();

    // Logs "log once" to the console and removes the listener
    logFnWrapper();

    emitter.on('log', () => console.log('log persistently'));
    // Will return a new Array with a single function bound by `.on()` above
    const newListeners = emitter.rawListeners('log');

    // Logs "log persistently" twice
    newListeners[0]();
    emitter.emit('log'); -
    -
    -
    -

    Type Parameters

    -
      -
    • - K -
    • -
    -
    -
    -

    Parameters

    -
      -
    • - eventName: - string - | - symbol -
    • -
    -
    -

    - Returns Function[] -

    -
    -
    - -

    v9.4.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - The readable.read() method reads data out of - the internal buffer and returns it. If no data is - available to be read, null is returned. By - default, the data is returned as a - Buffer object unless an encoding has been - specified using the - readable.setEncoding() method or the stream - is operating in object mode. -

    -

    - The optional size argument specifies a - specific number of bytes to read. If - size bytes are not available to be read, - null will be returned unless the - stream has ended, in which case all of the data remaining - in the internal buffer will be returned. -

    -

    - If the size argument is not specified, all of - the data contained in the internal buffer will be - returned. -

    -

    - The size argument must be less than or equal - to 1 GiB. -

    -

    - The readable.read() method should only be - called on Readable streams operating in - paused mode. In flowing mode, - readable.read() is called automatically until - the internal buffer is fully drained. -

    -
    const readable = getReadableStreamSomehow();

    // 'readable' may be triggered multiple times as data is buffered in
    readable.on('readable', () => {
    let chunk;
    console.log('Stream is readable (new data received in buffer)');
    // Use a loop to make sure we read all currently available data
    while (null !== (chunk = readable.read())) {
    console.log(`Read ${chunk.length} bytes of data...`);
    }
    });

    // 'end' will be triggered once when there is no more data available
    readable.on('end', () => {
    console.log('Reached end of stream.');
    }); -
    - -

    - Each call to readable.read() returns a chunk - of data, or null. The chunks are not - concatenated. A while loop is necessary to - consume all data currently in the buffer. When reading a - large file .read() may return - null, having consumed all buffered content so - far, but there is still more data to come not yet - buffered. In this case a new 'readable' event - will be emitted when there is more data in the buffer. - Finally the 'end' event will be emitted when - there is no more data to come. -

    -

    - Therefore to read a file's whole contents from a - readable, it is necessary to collect chunks - across multiple 'readable' events: -

    -
    const chunks = [];

    readable.on('readable', () => {
    let chunk;
    while (null !== (chunk = readable.read())) {
    chunks.push(chunk);
    }
    });

    readable.on('end', () => {
    const content = chunks.join('');
    }); -
    - -

    - A Readable stream in object mode will always - return a single item from a call to - readable.read(size), regardless of the value - of the size argument. -

    -

    - If the readable.read() method returns a chunk - of data, a 'data' event will also be emitted. -

    -

    - Calling - read - after the 'end' event has been emitted will - return null. No runtime error will be raised. -

    -
    -
    -

    Parameters

    -
      -
    • - Optionalsize: - number -
      -

      - Optional argument to specify how much data to read. -

      -
      -
      -
    • -
    -
    -

    - Returns any -

    -
    -
    - -

    v0.9.4

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - This method calls fn on each chunk of the stream - in order, passing it the result from the calculation on - the previous element. It returns a promise for the final - value of the reduction. -

    -

    - If no initial value is supplied the first chunk - of the stream is used as the initial value. If the stream - is empty, the promise is rejected with a - TypeError with the - ERR_INVALID_ARGS code property. -

    -

    - The reducer function iterates the stream - element-by-element which means that there is no - concurrency parameter or parallelism. To perform - a reduce concurrently, you can extract the async function - to readable.map method. -

    -
    -
    -

    Type Parameters

    -
      -
    • - T = - any -
    • -
    -
    -
    -

    Parameters

    -
      -
    • - fn: - (previous: - any, - data: - any, - options?: - Pick<ArrayOptions, - "signal">) - => - T -
      -

      - a reducer function to call over every chunk in the - stream. Async or not. -

      -
      -
      -
    • -
    • - Optionalinitial: - undefined -
      -

      the initial value to use in the reduction.

      -
      -
      -
    • -
    • - Optionaloptions: - Pick<ArrayOptions, - "signal"> -
    • -
    -
    -

    - Returns Promise<T> -

    -

    a promise for the final value of the reduction.

    -
    -
    - -

    v17.5.0

    -
    -
    - -
  • - -
  • -
    -

    - This method calls fn on each chunk of the stream - in order, passing it the result from the calculation on - the previous element. It returns a promise for the final - value of the reduction. -

    -

    - If no initial value is supplied the first chunk - of the stream is used as the initial value. If the stream - is empty, the promise is rejected with a - TypeError with the - ERR_INVALID_ARGS code property. -

    -

    - The reducer function iterates the stream - element-by-element which means that there is no - concurrency parameter or parallelism. To perform - a reduce concurrently, you can extract the async function - to readable.map method. -

    -
    -
    -

    Type Parameters

    -
      -
    • - T = - any -
    • -
    -
    -
    -

    Parameters

    -
      -
    • - fn: - (previous: - T, - data: - any, - options?: - Pick<ArrayOptions, - "signal">) - => - T -
      -

      - a reducer function to call over every chunk in the - stream. Async or not. -

      -
      -
      -
    • -
    • - initial: - T -
      -

      the initial value to use in the reduction.

      -
      -
      -
    • -
    • - Optionaloptions: - Pick<ArrayOptions, - "signal"> -
    • -
    -
    -

    - Returns Promise<T> -

    -

    a promise for the final value of the reduction.

    -
    -
    - -

    v17.5.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Removes all listeners, or those of the specified - eventName. -

    -

    - It is bad practice to remove listeners added elsewhere in - the code, particularly when the - EventEmitter instance was created by some - other component or module (e.g. sockets or file streams). -

    -

    - Returns a reference to the EventEmitter, so - that calls can be chained. -

    -
    -
    -

    Parameters

    -
      -
    • - OptionaleventName: - string - | - symbol -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.1.26

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Removes the specified listener from the - listener array for the event named eventName. -

    -
    const callback = (stream) => {
    console.log('someone connected!');
    };
    server.on('connection', callback);
    // ...
    server.removeListener('connection', callback); -
    - -

    - removeListener() will remove, at most, one - instance of a listener from the listener array. If any - single listener has been added multiple times to the - listener array for the specified eventName, - then removeListener() must be called multiple - times to remove each instance. -

    -

    - Once an event is emitted, all listeners attached to it at - the time of emitting are called in order. This implies - that any removeListener() or - removeAllListeners() calls - after emitting and before the last - listener finishes execution will not remove them fromemit() - in progress. Subsequent events behave as expected. -

    -
    import { EventEmitter } from 'node:events';
    class MyEmitter extends EventEmitter {}
    const myEmitter = new MyEmitter();

    const callbackA = () => {
    console.log('A');
    myEmitter.removeListener('event', callbackB);
    };

    const callbackB = () => {
    console.log('B');
    };

    myEmitter.on('event', callbackA);

    myEmitter.on('event', callbackB);

    // callbackA removes listener callbackB but it will still be called.
    // Internal listener array at time of emit [callbackA, callbackB]
    myEmitter.emit('event');
    // Prints:
    // A
    // B

    // callbackB is now removed.
    // Internal listener array [callbackA]
    myEmitter.emit('event');
    // Prints:
    // A -
    - -

    - Because listeners are managed using an internal array, - calling this will change the position indices of any - listener registered after the listener being - removed. This will not impact the order in which listeners - are called, but it means that any copies of the listener - array as returned by the - emitter.listeners() method will need to be - recreated. -

    -

    - When a single function has been added as a handler - multiple times for a single event (as in the example - below), removeListener() will remove the most - recently added instance. In the example the - once('ping') listener is removed: -

    -
    import { EventEmitter } from 'node:events';
    const ee = new EventEmitter();

    function pong() {
    console.log('pong');
    }

    ee.on('ping', pong);
    ee.once('ping', pong);
    ee.removeListener('ping', pong);

    ee.emit('ping');
    ee.emit('ping'); -
    - -

    - Returns a reference to the EventEmitter, so - that calls can be chained. -

    -
    -
    -

    Parameters

    -
      -
    • - event: - "close" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.1.26

    -
    -
    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "data" -
    • -
    • - listener: - (chunk: - any) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "end" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "error" -
    • -
    • - listener: - (err: - Error) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "pause" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "readable" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "resume" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - string - | - symbol -
    • -
    • - listener: - (...args: - any[]) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    - The readable.resume() method causes an - explicitly paused Readable stream to resume - emitting 'data' events, switching the stream - into flowing mode. -

    -

    - The readable.resume() method can be used to - fully consume the data from a stream without actually - processing any of that data: -

    -
    getReadableStreamSomehow()
    .resume()
    .on('end', () => {
    console.log('Reached the end, but did not read anything.');
    }); -
    - -

    - The readable.resume() method has no effect if - there is a 'readable' event listener. -

    -
    -

    - Returns this -

    -
    -
    - -

    v0.9.4

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - The readable.setEncoding() method sets the - character encoding for data read from the - Readable stream. -

    -

    - By default, no encoding is assigned and stream data will - be returned as Buffer objects. Setting an - encoding causes the stream data to be returned as strings - of the specified encoding rather than as - Buffer objects. For instance, calling - readable.setEncoding('utf8') will cause the - output data to be interpreted as UTF-8 data, and passed as - strings. Calling - readable.setEncoding('hex') will cause the - data to be encoded in hexadecimal string format. -

    -

    - The Readable stream will properly handle - multi-byte characters delivered through the stream that - would otherwise become improperly decoded if simply pulled - from the stream as Buffer objects. -

    -
    const readable = getReadableStreamSomehow();
    readable.setEncoding('utf8');
    readable.on('data', (chunk) => {
    assert.equal(typeof chunk, 'string');
    console.log('Got %d characters of string data:', chunk.length);
    }); -
    -
    -
    -

    Parameters

    -
      -
    • - encoding: - BufferEncoding -
      -

      The encoding to use.

      -
      -
      -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.9.4

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - By default EventEmitters will print a warning - if more than 10 listeners are added for a - particular event. This is a useful default that helps - finding memory leaks. The - emitter.setMaxListeners() method allows the - limit to be modified for this specific - EventEmitter instance. The value can be set - to Infinity (or 0) to indicate - an unlimited number of listeners. -

    -

    - Returns a reference to the EventEmitter, so - that calls can be chained. -

    -
    -
    -

    Parameters

    -
      -
    • - n: - number -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.3.5

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Calls - message.socket.setTimeout(msecs, callback). -

    -
    -
    -

    Parameters

    -
      -
    • - msecs: - number -
    • -
    • - Optionalcallback: - () - => - void -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.5.9

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - This method is similar to - Array.prototype.some and calls fn on - each chunk in the stream until the awaited return value is - true (or any truthy value). Once an - fn call on a chunk awaited return - value is truthy, the stream is destroyed and the promise - is fulfilled with true. If none of the - fn calls on the chunks return a truthy value, the - promise is fulfilled with false. -

    -
    -
    -

    Parameters

    -
      -
    • - fn: - (
          data: - any,
          options?: - Pick<ArrayOptions, - "signal">,
      ) - => - boolean - | - Promise<boolean>
      -
      -

      - a function to call on each chunk of the stream. - Async or not. -

      -
      -
      -
    • -
    • - Optionaloptions: - ArrayOptions -
    • -
    -
    -

    - Returns Promise<boolean> -

    -

    - a promise evaluating to true if - fn returned a truthy value for at least one of the - chunks. -

    -
    -
    - -

    v17.5.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - This method returns a new stream with the first - limit chunks. -

    -
    -
    -

    Parameters

    -
      -
    • - limit: - number -
      -

      the number of chunks to take from the readable.

      -
      -
      -
    • -
    • - Optionaloptions: - Pick<ArrayOptions, - "signal"> -
    • -
    -
    -

    - Returns Readable -

    -

    a stream with limit chunks taken.

    -
    -
    - -

    v17.5.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - This method allows easily obtaining the contents of a - stream. -

    -

    - As this method reads the entire stream into memory, it - negates the benefits of streams. It's intended for - interoperability and convenience, not as the primary way - to consume streams. -

    -
    -
    -

    Parameters

    -
      -
    • - Optionaloptions: - Pick<ArrayOptions, - "signal"> -
    • -
    -
    -

    - Returns Promise<any[]> -

    -

    - a promise containing an array with the contents of the - stream. -

    -
    -
    - -

    v17.5.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - The readable.unpipe() method detaches a - Writable stream previously attached using the - pipe - method. -

    -

    - If the destination is not specified, then - all pipes are detached. -

    -

    - If the destination is specified, but no pipe - is set up for it, then the method does nothing. -

    -
    const fs = require('node:fs');
    const readable = getReadableStreamSomehow();
    const writable = fs.createWriteStream('file.txt');
    // All the data from readable goes into 'file.txt',
    // but only for the first second.
    readable.pipe(writable);
    setTimeout(() => {
    console.log('Stop writing to file.txt.');
    readable.unpipe(writable);
    console.log('Manually close the file stream.');
    writable.end();
    }, 1000); -
    -
    -
    -

    Parameters

    -
      -
    • - Optionaldestination: - WritableStream -
      -

      Optional specific stream to unpipe

      -
      -
      -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.9.4

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Passing chunk as null signals - the end of the stream (EOF) and behaves the same as - readable.push(null), after which no more data - can be written. The EOF signal is put at the end of the - buffer and any buffered data will still be flushed. -

    -

    - The readable.unshift() method pushes a chunk - of data back into the internal buffer. This is useful in - certain situations where a stream is being consumed by - code that needs to "un-consume" some amount of - data that it has optimistically pulled out of the source, - so that the data can be passed on to some other party. -

    -

    - The stream.unshift(chunk) method cannot be - called after the 'end' event has been emitted - or a runtime error will be thrown. -

    -

    - Developers using stream.unshift() often - should consider switching to use of a - Transform stream instead. See the - API for stream implementers section for more - information. -

    -
    // Pull off a header delimited by \n\n.
    // Use unshift() if we get too much.
    // Call the callback with (error, header, stream).
    const { StringDecoder } = require('node:string_decoder');
    function parseHeader(stream, callback) {
    stream.on('error', callback);
    stream.on('readable', onReadable);
    const decoder = new StringDecoder('utf8');
    let header = '';
    function onReadable() {
    let chunk;
    while (null !== (chunk = stream.read())) {
    const str = decoder.write(chunk);
    if (str.includes('\n\n')) {
    // Found the header boundary.
    const split = str.split(/\n\n/);
    header += split.shift();
    const remaining = split.join('\n\n');
    const buf = Buffer.from(remaining, 'utf8');
    stream.removeListener('error', callback);
    // Remove the 'readable' listener before unshifting.
    stream.removeListener('readable', onReadable);
    if (buf.length)
    stream.unshift(buf);
    // Now the body of the message can be read from the stream.
    callback(null, header, stream);
    return;
    }
    // Still reading the header.
    header += str;
    }
    }
    } -
    - -

    - Unlike - push, stream.unshift(chunk) will not end the - reading process by resetting the internal reading state of - the stream. This can cause unexpected results if - readable.unshift() is called during a read - (i.e. from within a - _read - implementation on a custom stream). Following the call to - readable.unshift() with an immediate - push - will reset the reading state appropriately, however it is - best to simply avoid calling - readable.unshift() while in the process of - performing a read. -

    -
    -
    -

    Parameters

    -
      -
    • - chunk: - any -
      -

      - Chunk of data to unshift onto the read queue. For - streams not operating in object mode, - chunk must be a {string}, {Buffer}, - {TypedArray}, {DataView} or null. For - object mode streams, chunk may be any - JavaScript value. -

      -
      -
      -
    • -
    • - Optionalencoding: - BufferEncoding -
      -

      - Encoding of string chunks. Must be a valid - Buffer encoding, such as - 'utf8' or 'ascii'. -

      -
      -
      -
    • -
    -
    -

    - Returns void -

    -
    -
    - -

    v0.9.11

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Prior to Node.js 0.10, streams did not implement the - entire node:stream module API as it is - currently defined. (See Compatibility for - more information.) -

    -

    - When using an older Node.js library that emits - 'data' events and has a - pause - method that is advisory only, the - readable.wrap() method can be used to create - a Readable stream that uses the old stream as - its data source. -

    -

    - It will rarely be necessary to use - readable.wrap() but the method has been - provided as a convenience for interacting with older - Node.js applications and libraries. -

    -
    const { OldReader } = require('./old-api-module.js');
    const { Readable } = require('node:stream');
    const oreader = new OldReader();
    const myReader = new Readable().wrap(oreader);

    myReader.on('readable', () => {
    myReader.read(); // etc.
    }); -
    -
    -
    -

    Parameters

    -
      -
    • - stream: - ReadableStream -
      -

      An "old style" readable stream

      -
      -
      -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.9.4

    -
    -
    - -
  • -
-
-
- - -
    - -
  • - Experimental -
    -

    - Listens once to the abort event on the - provided signal. -

    -

    - Listening to the abort event on abort signals - is unsafe and may lead to resource leaks since another - third party with the signal can call - e.stopImmediatePropagation(). Unfortunately - Node.js cannot change this since it would violate the web - standard. Additionally, the original API makes it easy to - forget to remove listeners. -

    -

    - This API allows safely using AbortSignals in - Node.js APIs by solving these two issues by listening to - the event such that - stopImmediatePropagation does not prevent the - listener from running. -

    -

    - Returns a disposable so that it may be unsubscribed from - more easily. -

    -
    import { addAbortListener } from 'node:events';

    function example(signal) {
    let disposable;
    try {
    signal.addEventListener('abort', (e) => e.stopImmediatePropagation());
    disposable = addAbortListener(signal, (e) => {
    // Do something when signal is aborted.
    });
    } finally {
    disposable?.[Symbol.dispose]();
    }
    } -
    -
    -
    -

    Parameters

    -
      -
    • - signal: - AbortSignal -
    • -
    • - resource: - (event: - Event) - => - void -
    • -
    -
    -

    - Returns Disposable -

    -

    - Disposable that removes the abort listener. -

    -
    -
    - -

    v20.5.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - A utility method for creating Readable Streams out of - iterators. -

    -
    -
    -

    Parameters

    -
      -
    • - iterable: - Iterable<any> - | - AsyncIterable<any> -
      -

      - Object implementing the - Symbol.asyncIterator or - Symbol.iterator iterable protocol. - Emits an 'error' event if a null value is passed. -

      -
      -
      -
    • -
    • - Optionaloptions: - ReadableOptions -
      -

      - Options provided to - new stream.Readable([options]). By - default, Readable.from() will set - options.objectMode to - true, unless this is explicitly opted - out by setting options.objectMode to - false. -

      -
      -
      -
    • -
    -
    -

    - Returns Readable -

    -
    -
    - -

    v12.3.0, v10.17.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • - Experimental -
    -

    - A utility method for creating a Readable from - a web ReadableStream. -

    -
    -
    -

    Parameters

    -
      -
    • - readableStream: - ReadableStream -
    • -
    • - Optionaloptions: - Pick<ReadableOptions, - "signal" - | - "encoding" - | - "highWaterMark" - | - "objectMode"> -
    • -
    -
    -

    - Returns Readable -

    -
    -
    - -

    v17.0.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Returns a copy of the array of listeners for the event - named eventName. -

    -

    - For EventEmitters this behaves exactly the - same as calling .listeners on the emitter. -

    -

    - For EventTargets this is the only way to get - the event listeners for the event target. This is useful - for debugging and diagnostic purposes. -

    -
    import { getEventListeners, EventEmitter } from 'node:events';

    {
    const ee = new EventEmitter();
    const listener = () => console.log('Events are fun');
    ee.on('foo', listener);
    console.log(getEventListeners(ee, 'foo')); // [ [Function: listener] ]
    }
    {
    const et = new EventTarget();
    const listener = () => console.log('Events are fun');
    et.addEventListener('foo', listener);
    console.log(getEventListeners(et, 'foo')); // [ [Function: listener] ]
    } -
    -
    -
    -

    Parameters

    -
      -
    • - emitter: - EventTarget - | - EventEmitter -
    • -
    • - name: - string - | - symbol -
    • -
    -
    -

    - Returns Function[] -

    -
    -
    - -

    v15.2.0, v14.17.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    Returns the currently set max amount of listeners.

    -

    - For EventEmitters this behaves exactly the - same as calling .getMaxListeners on the - emitter. -

    -

    - For EventTargets this is the only way to get - the max event listeners for the event target. If the - number of event handlers on a single EventTarget exceeds - the max set, the EventTarget will print a warning. -

    -
    import { getMaxListeners, setMaxListeners, EventEmitter } from 'node:events';

    {
    const ee = new EventEmitter();
    console.log(getMaxListeners(ee)); // 10
    setMaxListeners(11, ee);
    console.log(getMaxListeners(ee)); // 11
    }
    {
    const et = new EventTarget();
    console.log(getMaxListeners(et)); // 10
    setMaxListeners(11, et);
    console.log(getMaxListeners(et)); // 11
    } -
    -
    -
    -

    Parameters

    -
      -
    • - emitter: - EventTarget - | - EventEmitter -
    • -
    -
    -

    - Returns number -

    -
    -
    - -

    v19.9.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Returns whether the stream has been read from or - cancelled. -

    -
    -
    -

    Parameters

    -
      -
    • - stream: - Readable - | - ReadableStream -
    • -
    -
    -

    - Returns boolean -

    -
    -
    - -

    v16.8.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - A class method that returns the number of listeners for - the given eventName registered on the given - emitter. -

    -
    import { EventEmitter, listenerCount } from 'node:events';

    const myEmitter = new EventEmitter();
    myEmitter.on('event', () => {});
    myEmitter.on('event', () => {});
    console.log(listenerCount(myEmitter, 'event'));
    // Prints: 2 -
    -
    -
    -

    Parameters

    -
      -
    • - emitter: - EventEmitter -
      -

      The emitter to query

      -
      -
      -
    • -
    • - eventName: - string - | - symbol -
      -

      The event name

      -
      -
      -
    • -
    -
    -

    - Returns number -

    -
    -
    - -

    v0.9.12

    -
    -
    - -

    - Since v3.2.0 - Use listenerCount instead. -

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -
    import { on, EventEmitter } from 'node:events';
    import process from 'node:process';

    const ee = new EventEmitter();

    // Emit later on
    process.nextTick(() => {
    ee.emit('foo', 'bar');
    ee.emit('foo', 42);
    });

    for await (const event of on(ee, 'foo')) {
    // The execution of this inner block is synchronous and it
    // processes one event at a time (even with await). Do not use
    // if concurrent execution is required.
    console.log(event); // prints ['bar'] [42]
    }
    // Unreachable here -
    - -

    - Returns an AsyncIterator that iterates - eventName events. It will throw if the - EventEmitter emits 'error'. It - removes all listeners when exiting the loop. The - value returned by each iteration is an array - composed of the emitted event arguments. -

    -

    - An AbortSignal can be used to cancel waiting - on events: -

    -
    import { on, EventEmitter } from 'node:events';
    import process from 'node:process';

    const ac = new AbortController();

    (async () => {
    const ee = new EventEmitter();

    // Emit later on
    process.nextTick(() => {
    ee.emit('foo', 'bar');
    ee.emit('foo', 42);
    });

    for await (const event of on(ee, 'foo', { signal: ac.signal })) {
    // The execution of this inner block is synchronous and it
    // processes one event at a time (even with await). Do not use
    // if concurrent execution is required.
    console.log(event); // prints ['bar'] [42]
    }
    // Unreachable here
    })();

    process.nextTick(() => ac.abort()); -
    - -

    - Use the close option to specify an array of - event names that will end the iteration: -

    -
    import { on, EventEmitter } from 'node:events';
    import process from 'node:process';

    const ee = new EventEmitter();

    // Emit later on
    process.nextTick(() => {
    ee.emit('foo', 'bar');
    ee.emit('foo', 42);
    ee.emit('close');
    });

    for await (const event of on(ee, 'foo', { close: ['close'] })) {
    console.log(event); // prints ['bar'] [42]
    }
    // the loop will exit after 'close' is emitted
    console.log('done'); // prints 'done' -
    -
    -
    -

    Parameters

    -
      -
    • - emitter: - EventEmitter -
    • -
    • - eventName: - string - | - symbol -
    • -
    • - Optionaloptions: - StaticEventEmitterIteratorOptions -
    • -
    -
    -

    - Returns - AsyncIterableIterator<any[]> -

    -

    - An AsyncIterator that iterates - eventName events emitted by the - emitter -

    -
    -
    - -

    v13.6.0, v12.16.0

    -
    -
    - -
  • - -
  • -
    -
    import { on, EventEmitter } from 'node:events';
    import process from 'node:process';

    const ee = new EventEmitter();

    // Emit later on
    process.nextTick(() => {
    ee.emit('foo', 'bar');
    ee.emit('foo', 42);
    });

    for await (const event of on(ee, 'foo')) {
    // The execution of this inner block is synchronous and it
    // processes one event at a time (even with await). Do not use
    // if concurrent execution is required.
    console.log(event); // prints ['bar'] [42]
    }
    // Unreachable here -
    - -

    - Returns an AsyncIterator that iterates - eventName events. It will throw if the - EventEmitter emits 'error'. It - removes all listeners when exiting the loop. The - value returned by each iteration is an array - composed of the emitted event arguments. -

    -

    - An AbortSignal can be used to cancel waiting - on events: -

    -
    import { on, EventEmitter } from 'node:events';
    import process from 'node:process';

    const ac = new AbortController();

    (async () => {
    const ee = new EventEmitter();

    // Emit later on
    process.nextTick(() => {
    ee.emit('foo', 'bar');
    ee.emit('foo', 42);
    });

    for await (const event of on(ee, 'foo', { signal: ac.signal })) {
    // The execution of this inner block is synchronous and it
    // processes one event at a time (even with await). Do not use
    // if concurrent execution is required.
    console.log(event); // prints ['bar'] [42]
    }
    // Unreachable here
    })();

    process.nextTick(() => ac.abort()); -
    - -

    - Use the close option to specify an array of - event names that will end the iteration: -

    -
    import { on, EventEmitter } from 'node:events';
    import process from 'node:process';

    const ee = new EventEmitter();

    // Emit later on
    process.nextTick(() => {
    ee.emit('foo', 'bar');
    ee.emit('foo', 42);
    ee.emit('close');
    });

    for await (const event of on(ee, 'foo', { close: ['close'] })) {
    console.log(event); // prints ['bar'] [42]
    }
    // the loop will exit after 'close' is emitted
    console.log('done'); // prints 'done' -
    -
    -
    -

    Parameters

    -
      -
    • - emitter: - EventTarget -
    • -
    • - eventName: - string -
    • -
    • - Optionaloptions: - StaticEventEmitterIteratorOptions -
    • -
    -
    -

    - Returns - AsyncIterableIterator<any[]> -

    -

    - An AsyncIterator that iterates - eventName events emitted by the - emitter -

    -
    -
    - -

    v13.6.0, v12.16.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Creates a Promise that is fulfilled when the - EventEmitter emits the given event or that is - rejected if the EventEmitter emits - 'error' while waiting. The - Promise will resolve with an array of all the - arguments emitted to the given event. -

    -

    - This method is intentionally generic and works with the - web platform - EventTarget - interface, which has no special'error' event - semantics and does not listen to the - 'error' event. -

    -
    import { once, EventEmitter } from 'node:events';
    import process from 'node:process';

    const ee = new EventEmitter();

    process.nextTick(() => {
    ee.emit('myevent', 42);
    });

    const [value] = await once(ee, 'myevent');
    console.log(value);

    const err = new Error('kaboom');
    process.nextTick(() => {
    ee.emit('error', err);
    });

    try {
    await once(ee, 'myevent');
    } catch (err) {
    console.error('error happened', err);
    } -
    - -

    - The special handling of the 'error' event is - only used when events.once() is used to wait - for another event. If events.once() is used - to wait for the 'error' event itself, then it - is treated as any other kind of event without special - handling: -

    -
    import { EventEmitter, once } from 'node:events';

    const ee = new EventEmitter();

    once(ee, 'error')
    .then(([err]) => console.log('ok', err.message))
    .catch((err) => console.error('error', err.message));

    ee.emit('error', new Error('boom'));

    // Prints: ok boom -
    - -

    - An AbortSignal can be used to cancel waiting - for the event: -

    -
    import { EventEmitter, once } from 'node:events';

    const ee = new EventEmitter();
    const ac = new AbortController();

    async function foo(emitter, event, signal) {
    try {
    await once(emitter, event, { signal });
    console.log('event emitted!');
    } catch (error) {
    if (error.name === 'AbortError') {
    console.error('Waiting for the event was canceled!');
    } else {
    console.error('There was an error', error.message);
    }
    }
    }

    foo(ee, 'foo', ac.signal);
    ac.abort(); // Abort waiting for the event
    ee.emit('foo'); // Prints: Waiting for the event was canceled! -
    -
    -
    -

    Parameters

    -
      -
    • - emitter: - EventEmitter -
    • -
    • - eventName: - string - | - symbol -
    • -
    • - Optionaloptions: - StaticEventEmitterOptions -
    • -
    -
    -

    - Returns Promise<any[]> -

    -
    -
    - -

    v11.13.0, v10.16.0

    -
    -
    - -
  • - -
  • -
    -

    - Creates a Promise that is fulfilled when the - EventEmitter emits the given event or that is - rejected if the EventEmitter emits - 'error' while waiting. The - Promise will resolve with an array of all the - arguments emitted to the given event. -

    -

    - This method is intentionally generic and works with the - web platform - EventTarget - interface, which has no special'error' event - semantics and does not listen to the - 'error' event. -

    -
    import { once, EventEmitter } from 'node:events';
    import process from 'node:process';

    const ee = new EventEmitter();

    process.nextTick(() => {
    ee.emit('myevent', 42);
    });

    const [value] = await once(ee, 'myevent');
    console.log(value);

    const err = new Error('kaboom');
    process.nextTick(() => {
    ee.emit('error', err);
    });

    try {
    await once(ee, 'myevent');
    } catch (err) {
    console.error('error happened', err);
    } -
    - -

    - The special handling of the 'error' event is - only used when events.once() is used to wait - for another event. If events.once() is used - to wait for the 'error' event itself, then it - is treated as any other kind of event without special - handling: -

    -
    import { EventEmitter, once } from 'node:events';

    const ee = new EventEmitter();

    once(ee, 'error')
    .then(([err]) => console.log('ok', err.message))
    .catch((err) => console.error('error', err.message));

    ee.emit('error', new Error('boom'));

    // Prints: ok boom -
    - -

    - An AbortSignal can be used to cancel waiting - for the event: -

    -
    import { EventEmitter, once } from 'node:events';

    const ee = new EventEmitter();
    const ac = new AbortController();

    async function foo(emitter, event, signal) {
    try {
    await once(emitter, event, { signal });
    console.log('event emitted!');
    } catch (error) {
    if (error.name === 'AbortError') {
    console.error('Waiting for the event was canceled!');
    } else {
    console.error('There was an error', error.message);
    }
    }
    }

    foo(ee, 'foo', ac.signal);
    ac.abort(); // Abort waiting for the event
    ee.emit('foo'); // Prints: Waiting for the event was canceled! -
    -
    -
    -

    Parameters

    -
      -
    • - emitter: - EventTarget -
    • -
    • - eventName: - string -
    • -
    • - Optionaloptions: - StaticEventEmitterOptions -
    • -
    -
    -

    - Returns Promise<any[]> -

    -
    -
    - -

    v11.13.0, v10.16.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -
    import { setMaxListeners, EventEmitter } from 'node:events';

    const target = new EventTarget();
    const emitter = new EventEmitter();

    setMaxListeners(5, target, emitter); -
    -
    -
    -

    Parameters

    -
      -
    • - Optionaln: - number -
      -

      - A non-negative number. The maximum number of - listeners per EventTarget event. -

      -
      -
      -
    • -
    • - ...eventTargets: - (EventTarget - | - EventEmitter)[] -
    • -
    -
    -

    - Returns void -

    -
    -
    - -

    v15.4.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • - Experimental -
    -

    - A utility method for creating a web - ReadableStream from a Readable. -

    -
    -
    -

    Parameters

    -
      -
    • - streamReadable: - Readable -
    • -
    -
    -

    - Returns - ReadableStream -

    -
    -
    - -

    v17.0.0

    -
    -
    - -
  • -
-
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/classes/BindenResponse.html b/packages/binden/docs/classes/BindenResponse.html deleted file mode 100644 index b5c8323b..00000000 --- a/packages/binden/docs/classes/BindenResponse.html +++ /dev/null @@ -1,12943 +0,0 @@ - - - - - - BindenResponse | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Class BindenResponse<Request>

-
-
-

Type Parameters

-
    -
  • - Request extends IncomingMessage = - IncomingMessage -
  • -
-
-
-

Hierarchy

-
    -
  • - ServerResponse<Request> -
      -
    • - BindenResponse -
    • -
    -
  • -
-
- -
-
-
- - - -
-
-

Constructors

- -
-
-

Properties

- -
-
-

Accessors

- -
-
-

Methods

- -
-
-
-
-
-
- -

- - - - Constructors -

-
-
-
- - -
    - -
  • -
    -

    Type Parameters

    -
      -
    • - Request extends IncomingMessage - = - IncomingMessage -
    • -
    -
    -
    -

    Parameters

    - -
    -

    - Returns - BindenResponse<Request> -

    - -
  • -
-
-
-
-
- -

- - - - Properties -

-
-
-
- - -
- chunkedEncoding: - boolean -
- -
-
- - -
- closed: - boolean -
-
-

- Is true after 'close' has been - emitted. -

-
-
-
- -

v18.0.0

-
-
- -
-
- - -
- connection: - null - | - Socket -
-
-

Alias of outgoingMessage.socket.

-
-
-
- -

v0.3.0

-
-
- -

- Since v15.12.0,v14.17.1 - Use socket instead. -

-
-
- -
-
- - -
- destroyed: - boolean -
-
-

- Is true after writable.destroy() has - been called. -

-
-
-
- -

v8.0.0

-
-
- -
-
- - -
- errored: - null - | - Error -
-
-

- Returns error if the stream has been destroyed with an error. -

-
-
-
- -

v18.0.0

-
-
- -
-
- - -
- finished: - boolean -
-
-
- -

Use writableEnded instead.

-
-
- -
-
- - -
- headersSent: - boolean -
-
-

- Read-only. true if the headers were sent, - otherwise false. -

-
-
-
- -

v0.9.3

-
-
- -
-
- - -
- req: - Request -
- -
-
- - -
- sendDate: - boolean -
- -
-
- - -
- shouldKeepAlive: - boolean -
- -
-
- - -
- socket: - null - | - Socket -
-
-

- Reference to the underlying socket. Usually, users will not - want to access this property. -

-

- After calling outgoingMessage.end(), this - property will be nulled. -

-
-
-
- -

v0.3.0

-
-
- -
-
- - -
- statusCode: - number -
-
-

- When using implicit headers (not calling - response.writeHead() explicitly), this property - controls the status code that will be sent to the client when - the headers get flushed. -

-
response.statusCode = 404;
-
- -

- After response header was sent to the client, this property - indicates the status code which was sent out. -

-
-
-
- -

v0.4.0

-
-
- -
-
- - -
- statusMessage: - string -
-
-

- When using implicit headers (not calling - response.writeHead() explicitly), this property - controls the status message that will be sent to the client - when the headers get flushed. If this is left as - undefined then the standard message for the - status code will be used. -

-
response.statusMessage = 'Not found';
-
- -

- After response header was sent to the client, this property - indicates the status message which was sent out. -

-
-
-
- -

v0.11.8

-
-
- -
-
- - -
- strictContentLength: - boolean -
-
-

- If set to true, Node.js will check whether the - Content-Length header value and the size of the - body, in bytes, are equal. Mismatching the - Content-Length header value will result in an - Error being thrown, identified by - code:``'ERR_HTTP_CONTENT_LENGTH_MISMATCH'. -

-
-
-
- -

v18.10.0, v16.18.0

-
-
- -
-
- - -
- useChunkedEncodingByDefault: - boolean -
- -
-
- - -
- writable: - boolean -
-
-

- Is true if it is safe to call - writable.write(), which means the stream has not - been destroyed, errored, or ended. -

-
-
-
- -

v11.4.0

-
-
- -
-
- - -
- writableCorked: - number -
-
-

- Number of times writable.uncork() needs to be - called in order to fully uncork the stream. -

-
-
-
- -

v13.2.0, v12.16.0

-
-
- -
-
- - -
- writableEnded: - boolean -
-
-

- Is true after writable.end() has - been called. This property does not indicate whether the data - has been flushed, for this use - writable.writableFinished instead. -

-
-
-
- -

v12.9.0

-
-
- -
-
- - -
- writableFinished: - boolean -
-
-

- Is set to true immediately before the - 'finish' event is emitted. -

-
-
-
- -

v12.6.0

-
-
- -
-
- - -
- writableHighWaterMark: - number -
-
-

- Return the value of highWaterMark passed when - creating this Writable. -

-
-
-
- -

v9.3.0

-
-
- -
-
- - -
- writableLength: - number -
-
-

- This property contains the number of bytes (or objects) in the - queue ready to be written. The value provides introspection - data regarding the status of the highWaterMark. -

-
-
-
- -

v9.4.0

-
-
- -
-
- - -
- writableNeedDrain: - boolean -
-
-

- Is true if the stream's buffer has been full and - stream will emit 'drain'. -

-
-
-
- -

v15.2.0, v14.17.0

-
-
- -
-
- - -
- writableObjectMode: - boolean -
-
-

- Getter for the property objectMode of a given - Writable stream. -

-
-
-
- -

v12.3.0

-
-
- -
-
- - -
- captureRejections: - boolean -
-
-

- Value: - boolean -

-

- Change the default captureRejections option on - all new EventEmitter objects. -

-
-
-
- -

v13.4.0, v12.16.0

-
-
- -
-
- - -
- captureRejectionSymbol: - typeof - captureRejectionSymbol -
-
-

Value: Symbol.for('nodejs.rejection')

-

See how to write a custom rejection handler.

-
-
-
- -

v13.4.0, v12.16.0

-
-
- -
-
- - -
- defaultMaxListeners: - number -
-
-

- By default, a maximum of 10 listeners can be - registered for any single event. This limit can be changed for - individual EventEmitter instances using the - emitter.setMaxListeners(n) method. To change the - default for allEventEmitter instances, - the events.defaultMaxListeners property can be - used. If this value is not a positive number, a - RangeError is thrown. -

-

- Take caution when setting the - events.defaultMaxListeners because the change - affects all EventEmitter instances, - including those created before the change is made. However, - calling emitter.setMaxListeners(n) still has - precedence over events.defaultMaxListeners. -

-

- This is not a hard limit. The - EventEmitter instance will allow more listeners - to be added but will output a trace warning to stderr - indicating that a "possible EventEmitter memory - leak" has been detected. For any single - EventEmitter, the - emitter.getMaxListeners() and - emitter.setMaxListeners() methods can be used to - temporarily avoid this warning: -

-
import { EventEmitter } from 'node:events';
const emitter = new EventEmitter();
emitter.setMaxListeners(emitter.getMaxListeners() + 1);
emitter.once('event', () => {
// do stuff
emitter.setMaxListeners(Math.max(emitter.getMaxListeners() - 1, 0));
}); -
- -

- The --trace-warnings command-line flag can be - used to display the stack trace for such warnings. -

-

- The emitted warning can be inspected with - process.on('warning') and will have the - additional emitter, type, and - count properties, referring to the event emitter - instance, the event's name and the number of attached - listeners, respectively. Its name property is set - to 'MaxListenersExceededWarning'. -

-
-
-
- -

v0.11.2

-
-
- -
-
- - -
- errorMonitor: - typeof - errorMonitor -
-
-

- This symbol shall be used to install a listener for only - monitoring 'error' events. Listeners installed - using this symbol are called before the regular - 'error' listeners are called. -

-

- Installing a listener using this symbol does not change the - behavior once an 'error' event is emitted. - Therefore, the process will still crash if no regular - 'error' listener is installed. -

-
-
-
- -

v13.6.0, v12.17.0

-
-
- -
-
-
-
- -

- - - - Accessors -

-
-
-
- - - -
-
-
-
- -

- - - - Methods -

-
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - callback: - (error?: - null - | - Error) - => - void -
    • -
    -
    -

    - Returns void -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - error: - null - | - Error -
    • -
    • - callback: - (error?: - null - | - Error) - => - void -
    • -
    -
    -

    - Returns void -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - callback: - (error?: - null - | - Error) - => - void -
    • -
    -
    -

    - Returns void -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - chunk: - any -
    • -
    • - encoding: - BufferEncoding -
    • -
    • - callback: - (error?: - null - | - Error) - => - void -
    • -
    -
    -

    - Returns void -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - chunks: - { - chunk: - any; - encoding: - BufferEncoding - }[] -
    • -
    • - callback: - (error?: - null - | - Error) - => - void -
    • -
    -
    -

    - Returns void -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    Type Parameters

    -
      -
    • - K -
    • -
    -
    -
    -

    Parameters

    -
      -
    • - error: - Error -
    • -
    • - event: - string - | - symbol -
    • -
    • - ...args: - AnyRest -
    • -
    -
    -

    - Returns void -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Event emitter The defined events on documents including: -

    -
      -
    1. close
    2. -
    3. drain
    4. -
    5. error
    6. -
    7. finish
    8. -
    9. pipe
    10. -
    11. unpipe
    12. -
    -
    -
    -

    Parameters

    -
      -
    • - event: - "close" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    -
    - -
  • - -
  • -
    -

    - Event emitter The defined events on documents including: -

    -
      -
    1. close
    2. -
    3. drain
    4. -
    5. error
    6. -
    7. finish
    8. -
    9. pipe
    10. -
    11. unpipe
    12. -
    -
    -
    -

    Parameters

    -
      -
    • - event: - "drain" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    -
    - -
  • - -
  • -
    -

    - Event emitter The defined events on documents including: -

    -
      -
    1. close
    2. -
    3. drain
    4. -
    5. error
    6. -
    7. finish
    8. -
    9. pipe
    10. -
    11. unpipe
    12. -
    -
    -
    -

    Parameters

    -
      -
    • - event: - "error" -
    • -
    • - listener: - (err: - Error) - => - void -
    • -
    -
    -

    - Returns this -

    -
    - -
  • - -
  • -
    -

    - Event emitter The defined events on documents including: -

    -
      -
    1. close
    2. -
    3. drain
    4. -
    5. error
    6. -
    7. finish
    8. -
    9. pipe
    10. -
    11. unpipe
    12. -
    -
    -
    -

    Parameters

    -
      -
    • - event: - "finish" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    -
    - -
  • - -
  • -
    -

    - Event emitter The defined events on documents including: -

    -
      -
    1. close
    2. -
    3. drain
    4. -
    5. error
    6. -
    7. finish
    8. -
    9. pipe
    10. -
    11. unpipe
    12. -
    -
    -
    -

    Parameters

    -
      -
    • - event: - "pipe" -
    • -
    • - listener: - (src: - Readable) - => - void -
    • -
    -
    -

    - Returns this -

    -
    - -
  • - -
  • -
    -

    - Event emitter The defined events on documents including: -

    -
      -
    1. close
    2. -
    3. drain
    4. -
    5. error
    6. -
    7. finish
    8. -
    9. pipe
    10. -
    11. unpipe
    12. -
    -
    -
    -

    Parameters

    -
      -
    • - event: - "unpipe" -
    • -
    • - listener: - (src: - Readable) - => - void -
    • -
    -
    -

    - Returns this -

    -
    - -
  • - -
  • -
    -

    - Event emitter The defined events on documents including: -

    -
      -
    1. close
    2. -
    3. drain
    4. -
    5. error
    6. -
    7. finish
    8. -
    9. pipe
    10. -
    11. unpipe
    12. -
    -
    -
    -

    Parameters

    -
      -
    • - event: - string - | - symbol -
    • -
    • - listener: - (...args: - any[]) - => - void -
    • -
    -
    -

    - Returns this -

    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Adds HTTP trailers (headers but at the end of the message) - to the message. -

    -

    - Trailers will only be emitted if the - message is chunked encoded. If not, the trailers will be - silently discarded. -

    -

    - HTTP requires the Trailer header to be sent - to emit trailers, with a list of header field names in its - value, e.g. -

    -
    message.writeHead(200, { 'Content-Type': 'text/plain',
    'Trailer': 'Content-MD5' });
    message.write(fileData);
    message.addTrailers({ 'Content-MD5': '7895bf4b8828b55ceaf47747b4bca667' });
    message.end(); -
    - -

    - Attempting to set a header field name or value that - contains invalid characters will result in a - TypeError being thrown. -

    -
    -
    -

    Parameters

    -
      -
    • - headers: - OutgoingHttpHeaders - | - readonly - [string, - string][] -
    • -
    -
    -

    - Returns void -

    -
    -
    - -

    v0.3.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    Append a single header value to the header object.

    -

    - If the value is an array, this is equivalent to calling - this method multiple times. -

    -

    - If there were no previous values for the header, this is - equivalent to calling - outgoingMessage.setHeader(name, value). -

    -

    - Depending of the value of - options.uniqueHeaders when the client request - or the server were created, this will end up in the header - being sent multiple times or a single time with values - joined using ; . -

    -
    -
    -

    Parameters

    -
      -
    • - name: - string -
      -

      Header name

      -
      -
      -
    • -
    • - value: - string - | - readonly - string[] -
      -

      Header value

      -
      -
      -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v18.3.0, v16.17.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - socket: - Socket -
    • -
    -
    -

    - Returns void -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    Type Parameters

    -
      -
    • - T extends ReadableStream -
    • -
    -
    -
    -

    Parameters

    -
      -
    • - stream: - ComposeFnParam - | - T - | - Iterable<T> - | - AsyncIterable<T> -
    • -
    • - Optionaloptions: - { - signal: - AbortSignal - } -
    • -
    -
    -

    - Returns - T -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    - The writable.cork() method forces all written - data to be buffered in memory. The buffered data will be - flushed when either the - uncork - or - end - methods are called. -

    -

    - The primary intent of writable.cork() is to - accommodate a situation in which several small chunks are - written to the stream in rapid succession. Instead of - immediately forwarding them to the underlying destination, - writable.cork() buffers all the chunks until - writable.uncork() is called, which will pass - them all to writable._writev(), if present. - This prevents a head-of-line blocking situation where data - is being buffered while waiting for the first small chunk - to be processed. However, use of - writable.cork() without implementing - writable._writev() may have an adverse effect - on throughput. -

    -

    - See also: writable.uncork(), - writable._writev(). -

    -
    -

    - Returns void -

    -
    -
    - -

    v0.11.2

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Destroy the stream. Optionally emit an - 'error' event, and emit a - 'close' event (unless - emitClose is set to false). - After this call, the writable stream has ended and - subsequent calls to write() or - end() will result in an - ERR_STREAM_DESTROYED error. This is a - destructive and immediate way to destroy a stream. - Previous calls to write() may not have - drained, and may trigger an - ERR_STREAM_DESTROYED error. Use - end() instead of destroy if data should flush - before close, or wait for the 'drain' event - before destroying the stream. -

    -

    - Once destroy() has been called any further - calls will be a no-op and no further errors except from - _destroy() may be emitted as - 'error'. -

    -

    - Implementors should not override this method, but instead - implement writable._destroy(). -

    -
    -
    -

    Parameters

    -
      -
    • - Optionalerror: - Error -
      -

      - Optional, an error to emit with - 'error' event. -

      -
      -
      -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v8.0.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - socket: - Socket -
    • -
    -
    -

    - Returns void -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Synchronously calls each of the listeners registered for - the event named eventName, in the order they - were registered, passing the supplied arguments to each. -

    -

    - Returns true if the event had listeners, - false otherwise. -

    -
    import { EventEmitter } from 'node:events';
    const myEmitter = new EventEmitter();

    // First listener
    myEmitter.on('event', function firstListener() {
    console.log('Helloooo! first listener');
    });
    // Second listener
    myEmitter.on('event', function secondListener(arg1, arg2) {
    console.log(`event with parameters ${arg1}, ${arg2} in second listener`);
    });
    // Third listener
    myEmitter.on('event', function thirdListener(...args) {
    const parameters = args.join(', ');
    console.log(`event with parameters ${parameters} in third listener`);
    });

    console.log(myEmitter.listeners('event'));

    myEmitter.emit('event', 1, 2, 3, 4, 5);

    // Prints:
    // [
    // [Function: firstListener],
    // [Function: secondListener],
    // [Function: thirdListener]
    // ]
    // Helloooo! first listener
    // event with parameters 1, 2 in second listener
    // event with parameters 1, 2, 3, 4, 5 in third listener -
    -
    -
    -

    Parameters

    -
      -
    • - event: - "close" -
    • -
    -
    -

    - Returns boolean -

    -
    -
    - -

    v0.1.26

    -
    -
    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "drain" -
    • -
    -
    -

    - Returns boolean -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "error" -
    • -
    • - err: - Error -
    • -
    -
    -

    - Returns boolean -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "finish" -
    • -
    -
    -

    - Returns boolean -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "pipe" -
    • -
    • - src: - Readable -
    • -
    -
    -

    - Returns boolean -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "unpipe" -
    • -
    • - src: - Readable -
    • -
    -
    -

    - Returns boolean -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - string - | - symbol -
    • -
    • - ...args: - any[] -
    • -
    -
    -

    - Returns boolean -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Calling the writable.end() method signals - that no more data will be written to the - Writable. The optional chunk and - encoding arguments allow one final additional - chunk of data to be written immediately before closing the - stream. -

    -

    - Calling the - write - method after calling - end - will raise an error. -

    -
    // Write 'hello, ' and then end with 'world!'.
    const fs = require('node:fs');
    const file = fs.createWriteStream('example.txt');
    file.write('hello, ');
    file.end('world!');
    // Writing more now is not allowed! -
    -
    -
    -

    Parameters

    -
      -
    • - Optionalcb: - () - => - void -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.9.4

    -
    -
    - -
  • - -
  • -
    -

    - Calling the writable.end() method signals - that no more data will be written to the - Writable. The optional chunk and - encoding arguments allow one final additional - chunk of data to be written immediately before closing the - stream. -

    -

    - Calling the - write - method after calling - end - will raise an error. -

    -
    // Write 'hello, ' and then end with 'world!'.
    const fs = require('node:fs');
    const file = fs.createWriteStream('example.txt');
    file.write('hello, ');
    file.end('world!');
    // Writing more now is not allowed! -
    -
    -
    -

    Parameters

    -
      -
    • - chunk: - any -
      -

      - Optional data to write. For streams not operating in - object mode, chunk must be a {string}, - {Buffer}, {TypedArray} or {DataView}. For object - mode streams, chunk may be any - JavaScript value other than null. -

      -
      -
      -
    • -
    • - Optionalcb: - () - => - void -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.9.4

    -
    -
    - -
  • - -
  • -
    -

    - Calling the writable.end() method signals - that no more data will be written to the - Writable. The optional chunk and - encoding arguments allow one final additional - chunk of data to be written immediately before closing the - stream. -

    -

    - Calling the - write - method after calling - end - will raise an error. -

    -
    // Write 'hello, ' and then end with 'world!'.
    const fs = require('node:fs');
    const file = fs.createWriteStream('example.txt');
    file.write('hello, ');
    file.end('world!');
    // Writing more now is not allowed! -
    -
    -
    -

    Parameters

    -
      -
    • - chunk: - any -
      -

      - Optional data to write. For streams not operating in - object mode, chunk must be a {string}, - {Buffer}, {TypedArray} or {DataView}. For object - mode streams, chunk may be any - JavaScript value other than null. -

      -
      -
      -
    • -
    • - encoding: - BufferEncoding -
      -

      The encoding if chunk is a string

      -
      -
      -
    • -
    • - Optionalcb: - () - => - void -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.9.4

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Returns an array listing the events for which the emitter - has registered listeners. The values in the array are - strings or Symbols. -

    -
    import { EventEmitter } from 'node:events';

    const myEE = new EventEmitter();
    myEE.on('foo', () => {});
    myEE.on('bar', () => {});

    const sym = Symbol('symbol');
    myEE.on(sym, () => {});

    console.log(myEE.eventNames());
    // Prints: [ 'foo', 'bar', Symbol(symbol) ] -
    -
    -

    - Returns (string - | - symbol)[] -

    -
    -
    - -

    v6.0.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    Flushes the message headers.

    -

    - For efficiency reason, Node.js normally buffers the - message headers until - outgoingMessage.end() is called or the first - chunk of message data is written. It then tries to pack - the headers and data into a single TCP packet. -

    -

    - It is usually desired (it saves a TCP round-trip), but not - when the first data is not sent until possibly much later. - outgoingMessage.flushHeaders() bypasses the - optimization and kickstarts the message. -

    -
    -

    - Returns void -

    -
    -
    - -

    v1.6.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Send response as - application/x-www-form-urlencoded -

    -
    -
    -

    Parameters

    -
      -
    • - params: - URLSearchParams -
    • -
    -
    -

    - Returns Promise<void> -

    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Gets the value of the HTTP header with the given name. If - that header is not set, the returned value will be - undefined. -

    -
    -
    -

    Parameters

    -
      -
    • - name: - string -
      -

      Name of header

      -
      -
      -
    • -
    -
    -

    - Returns undefined - | - string - | - number - | - string[] -

    -
    -
    - -

    v0.4.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Returns an array containing the unique names of the - current outgoing headers. All names are lowercase. -

    -
    -

    - Returns string[] -

    -
    -
    - -

    v7.7.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Returns a shallow copy of the current outgoing headers. - Since a shallow copy is used, array values may be mutated - without additional calls to various header-related HTTP - module methods. The keys of the returned object are the - header names and the values are the respective header - values. All header names are lowercase. -

    -

    - The object returned by the - outgoingMessage.getHeaders() method does not - prototypically inherit from the JavaScript - Object. This means that typical - Object methods such as - obj.toString(), - obj.hasOwnProperty(), and others are not - defined and will not work. -

    -
    outgoingMessage.setHeader('Foo', 'bar');
    outgoingMessage.setHeader('Set-Cookie', ['foo=bar', 'bar=baz']);

    const headers = outgoingMessage.getHeaders();
    // headers === { foo: 'bar', 'set-cookie': ['foo=bar', 'bar=baz'] } -
    -
    -

    - Returns - OutgoingHttpHeaders -

    -
    -
    - -

    v7.7.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Returns the current max listener value for the - EventEmitter which is either set by - emitter.setMaxListeners(n) or defaults to - defaultMaxListeners. -

    -
    -

    - Returns number -

    -
    -
    - -

    v1.0.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Returns true if the header identified by - name is currently set in the outgoing - headers. The header name is case-insensitive. -

    -
    const hasContentType = outgoingMessage.hasHeader('content-type');
    -
    -
    -
    -

    Parameters

    -
      -
    • - name: - string -
    • -
    -
    -

    - Returns boolean -

    -
    -
    - -

    v7.7.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    Send response as text/html

    -
    -
    -

    Parameters

    -
      -
    • - data: - string -
    • -
    -
    -

    - Returns Promise<void> -

    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    Send response as application/json

    -
    -
    -

    Parameters

    -
      -
    • - data: - unknown[] - | - Record<string, - unknown> -
    • -
    • - stringify: - (input: - unknown[] - | - Record<string, - unknown>) - => - string - = ... -
    • -
    -
    -

    - Returns Promise<void> -

    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Returns the number of listeners listening for the event - named eventName. If listener is - provided, it will return how many times the listener is - found in the list of the listeners of the event. -

    -
    -
    -

    Type Parameters

    -
      -
    • - K -
    • -
    -
    -
    -

    Parameters

    -
      -
    • - eventName: - string - | - symbol -
      -

      The name of the event being listened for

      -
      -
      -
    • -
    • - Optionallistener: - Function -
      -

      The event handler function

      -
      -
      -
    • -
    -
    -

    - Returns number -

    -
    -
    - -

    v3.2.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Returns a copy of the array of listeners for the event - named eventName. -

    -
    server.on('connection', (stream) => {
    console.log('someone connected!');
    });
    console.log(util.inspect(server.listeners('connection')));
    // Prints: [ [Function] ] -
    -
    -
    -

    Type Parameters

    -
      -
    • - K -
    • -
    -
    -
    -

    Parameters

    -
      -
    • - eventName: - string - | - symbol -
    • -
    -
    -

    - Returns Function[] -

    -
    -
    - -

    v0.1.26

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    Alias for emitter.removeListener().

    -
    -
    -

    Type Parameters

    -
      -
    • - K -
    • -
    -
    -
    -

    Parameters

    -
      -
    • - eventName: - string - | - symbol -
    • -
    • - listener: - (...args: - any[]) - => - void -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v10.0.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Adds the listener function to the end of the - listeners array for the event named - eventName. No checks are made to see if the - listener has already been added. Multiple - calls passing the same combination of - eventName and listener will - result in the listener being added, and - called, multiple times. -

    -
    server.on('connection', (stream) => {
    console.log('someone connected!');
    }); -
    - -

    - Returns a reference to the EventEmitter, so - that calls can be chained. -

    -

    - By default, event listeners are invoked in the order they - are added. The - emitter.prependListener() method can be used - as an alternative to add the event listener to the - beginning of the listeners array. -

    -
    import { EventEmitter } from 'node:events';
    const myEE = new EventEmitter();
    myEE.on('foo', () => console.log('a'));
    myEE.prependListener('foo', () => console.log('b'));
    myEE.emit('foo');
    // Prints:
    // b
    // a -
    -
    -
    -

    Parameters

    -
      -
    • - event: - "close" -
    • -
    • - listener: - () - => - void -
      -

      The callback function

      -
      -
      -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.1.101

    -
    -
    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "drain" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "error" -
    • -
    • - listener: - (err: - Error) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "finish" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "pipe" -
    • -
    • - listener: - (src: - Readable) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "unpipe" -
    • -
    • - listener: - (src: - Readable) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - string - | - symbol -
    • -
    • - listener: - (...args: - any[]) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Adds a one-time - listener function for the event named - eventName. The next time - eventName is triggered, this listener is - removed and then invoked. -

    -
    server.once('connection', (stream) => {
    console.log('Ah, we have our first user!');
    }); -
    - -

    - Returns a reference to the EventEmitter, so - that calls can be chained. -

    -

    - By default, event listeners are invoked in the order they - are added. The - emitter.prependOnceListener() method can be - used as an alternative to add the event listener to the - beginning of the listeners array. -

    -
    import { EventEmitter } from 'node:events';
    const myEE = new EventEmitter();
    myEE.once('foo', () => console.log('a'));
    myEE.prependOnceListener('foo', () => console.log('b'));
    myEE.emit('foo');
    // Prints:
    // b
    // a -
    -
    -
    -

    Parameters

    -
      -
    • - event: - "close" -
    • -
    • - listener: - () - => - void -
      -

      The callback function

      -
      -
      -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.3.0

    -
    -
    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "drain" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "error" -
    • -
    • - listener: - (err: - Error) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "finish" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "pipe" -
    • -
    • - listener: - (src: - Readable) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "unpipe" -
    • -
    • - listener: - (src: - Readable) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - string - | - symbol -
    • -
    • - listener: - (...args: - any[]) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    Type Parameters

    -
      -
    • - T extends WritableStream -
    • -
    -
    -
    -

    Parameters

    -
      -
    • - destination: - T -
    • -
    • - Optionaloptions: - { - end?: - boolean - } -
    • -
    -
    -

    - Returns - T -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Adds the listener function to the - beginning of the listeners array for the event - named eventName. No checks are made to see if - the listener has already been added. Multiple - calls passing the same combination of - eventName and listener will - result in the listener being added, and - called, multiple times. -

    -
    server.prependListener('connection', (stream) => {
    console.log('someone connected!');
    }); -
    - -

    - Returns a reference to the EventEmitter, so - that calls can be chained. -

    -
    -
    -

    Parameters

    -
      -
    • - event: - "close" -
    • -
    • - listener: - () - => - void -
      -

      The callback function

      -
      -
      -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v6.0.0

    -
    -
    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "drain" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "error" -
    • -
    • - listener: - (err: - Error) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "finish" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "pipe" -
    • -
    • - listener: - (src: - Readable) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "unpipe" -
    • -
    • - listener: - (src: - Readable) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - string - | - symbol -
    • -
    • - listener: - (...args: - any[]) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Adds a one-timelistener function for the event named - eventName to the beginning of the - listeners array. The next time eventName is - triggered, this listener is removed, and then invoked. -

    -
    server.prependOnceListener('connection', (stream) => {
    console.log('Ah, we have our first user!');
    }); -
    - -

    - Returns a reference to the EventEmitter, so - that calls can be chained. -

    -
    -
    -

    Parameters

    -
      -
    • - event: - "close" -
    • -
    • - listener: - () - => - void -
      -

      The callback function

      -
      -
      -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v6.0.0

    -
    -
    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "drain" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "error" -
    • -
    • - listener: - (err: - Error) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "finish" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "pipe" -
    • -
    • - listener: - (src: - Readable) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "unpipe" -
    • -
    • - listener: - (src: - Readable) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - string - | - symbol -
    • -
    • - listener: - (...args: - any[]) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Returns a copy of the array of listeners for the event - named eventName, including any wrappers (such - as those created by .once()). -

    -
    import { EventEmitter } from 'node:events';
    const emitter = new EventEmitter();
    emitter.once('log', () => console.log('log once'));

    // Returns a new Array with a function `onceWrapper` which has a property
    // `listener` which contains the original listener bound above
    const listeners = emitter.rawListeners('log');
    const logFnWrapper = listeners[0];

    // Logs "log once" to the console and does not unbind the `once` event
    logFnWrapper.listener();

    // Logs "log once" to the console and removes the listener
    logFnWrapper();

    emitter.on('log', () => console.log('log persistently'));
    // Will return a new Array with a single function bound by `.on()` above
    const newListeners = emitter.rawListeners('log');

    // Logs "log persistently" twice
    newListeners[0]();
    emitter.emit('log'); -
    -
    -
    -

    Type Parameters

    -
      -
    • - K -
    • -
    -
    -
    -

    Parameters

    -
      -
    • - eventName: - string - | - symbol -
    • -
    -
    -

    - Returns Function[] -

    -
    -
    - -

    v9.4.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Removes all listeners, or those of the specified - eventName. -

    -

    - It is bad practice to remove listeners added elsewhere in - the code, particularly when the - EventEmitter instance was created by some - other component or module (e.g. sockets or file streams). -

    -

    - Returns a reference to the EventEmitter, so - that calls can be chained. -

    -
    -
    -

    Parameters

    -
      -
    • - OptionaleventName: - string - | - symbol -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.1.26

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    Removes a header that is queued for implicit sending.

    -
    outgoingMessage.removeHeader('Content-Encoding');
    -
    -
    -
    -

    Parameters

    -
      -
    • - name: - string -
      -

      Header name

      -
      -
      -
    • -
    -
    -

    - Returns void -

    -
    -
    - -

    v0.4.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Removes the specified listener from the - listener array for the event named eventName. -

    -
    const callback = (stream) => {
    console.log('someone connected!');
    };
    server.on('connection', callback);
    // ...
    server.removeListener('connection', callback); -
    - -

    - removeListener() will remove, at most, one - instance of a listener from the listener array. If any - single listener has been added multiple times to the - listener array for the specified eventName, - then removeListener() must be called multiple - times to remove each instance. -

    -

    - Once an event is emitted, all listeners attached to it at - the time of emitting are called in order. This implies - that any removeListener() or - removeAllListeners() calls - after emitting and before the last - listener finishes execution will not remove them fromemit() - in progress. Subsequent events behave as expected. -

    -
    import { EventEmitter } from 'node:events';
    class MyEmitter extends EventEmitter {}
    const myEmitter = new MyEmitter();

    const callbackA = () => {
    console.log('A');
    myEmitter.removeListener('event', callbackB);
    };

    const callbackB = () => {
    console.log('B');
    };

    myEmitter.on('event', callbackA);

    myEmitter.on('event', callbackB);

    // callbackA removes listener callbackB but it will still be called.
    // Internal listener array at time of emit [callbackA, callbackB]
    myEmitter.emit('event');
    // Prints:
    // A
    // B

    // callbackB is now removed.
    // Internal listener array [callbackA]
    myEmitter.emit('event');
    // Prints:
    // A -
    - -

    - Because listeners are managed using an internal array, - calling this will change the position indices of any - listener registered after the listener being - removed. This will not impact the order in which listeners - are called, but it means that any copies of the listener - array as returned by the - emitter.listeners() method will need to be - recreated. -

    -

    - When a single function has been added as a handler - multiple times for a single event (as in the example - below), removeListener() will remove the most - recently added instance. In the example the - once('ping') listener is removed: -

    -
    import { EventEmitter } from 'node:events';
    const ee = new EventEmitter();

    function pong() {
    console.log('pong');
    }

    ee.on('ping', pong);
    ee.once('ping', pong);
    ee.removeListener('ping', pong);

    ee.emit('ping');
    ee.emit('ping'); -
    - -

    - Returns a reference to the EventEmitter, so - that calls can be chained. -

    -
    -
    -

    Parameters

    -
      -
    • - event: - "close" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.1.26

    -
    -
    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "drain" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "error" -
    • -
    • - listener: - (err: - Error) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "finish" -
    • -
    • - listener: - () - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "pipe" -
    • -
    • - listener: - (src: - Readable) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - "unpipe" -
    • -
    • - listener: - (src: - Readable) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • - -
  • -
    -

    Parameters

    -
      -
    • - event: - string - | - symbol -
    • -
    • - listener: - (...args: - any[]) - => - void -
    • -
    -
    -

    - Returns this -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    Send a response

    -
    -
    -

    Parameters

    -
      -
    • - Optionaldata: - string - | - number - | - bigint - | - Readable - | - Buffer -
    • -
    -
    -

    - Returns Promise<void> -

    -
    - -
  • - -
  • -
    -

    Send a response

    -
    -
    -

    Parameters

    -
      -
    • - data: - string -
    • -
    • - encoding: - BufferEncoding -
    • -
    -
    -

    - Returns Promise<void> -

    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    Send a file

    -
    -
    -

    Parameters

    -
      -
    • - path: - string - | - URL -
    • -
    • - Optionaloptions: - Pick<Stats, - "size" - | - "isFile" - | - "mtime"> -
    • -
    -
    -

    - Returns Promise<void> -

    -
    - -
  • -
-
-
- - - -
-
- - -
    - -
  • -
    -

    - The writable.setDefaultEncoding() method sets - the default encoding for a - Writable stream. -

    -
    -
    -

    Parameters

    -
      -
    • - encoding: - BufferEncoding -
      -

      The new default encoding

      -
      -
      -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.11.15

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Sets a single header value. If the header already exists - in the to-be-sent headers, its value will be replaced. Use - an array of strings to send multiple headers with the same - name. -

    -
    -
    -

    Parameters

    -
      -
    • - name: - string -
      -

      Header name

      -
      -
      -
    • -
    • - value: - string - | - number - | - readonly - string[] -
      -

      Header value

      -
      -
      -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.4.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - By default EventEmitters will print a warning - if more than 10 listeners are added for a - particular event. This is a useful default that helps - finding memory leaks. The - emitter.setMaxListeners() method allows the - limit to be modified for this specific - EventEmitter instance. The value can be set - to Infinity (or 0) to indicate - an unlimited number of listeners. -

    -

    - Returns a reference to the EventEmitter, so - that calls can be chained. -

    -
    -
    -

    Parameters

    -
      -
    • - n: - number -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.3.5

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Once a socket is associated with the message and is - connected, socket.setTimeout() will be called - with msecs as the first parameter. -

    -
    -
    -

    Parameters

    -
      -
    • - msecs: - number -
    • -
    • - Optionalcallback: - () - => - void -
      -

      - Optional function to be called when a timeout - occurs. Same as binding to the - timeout event. -

      -
      -
      -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.9.12

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    Set the status code

    -
    -
    -

    Parameters

    -
      -
    • - code: - number -
    • -
    -
    -

    - Returns this -

    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    Send response as plain/text

    -
    -
    -

    Parameters

    -
      -
    • - data: - string -
    • -
    -
    -

    - Returns Promise<void> -

    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - The writable.uncork() method flushes all data - buffered since - cork - was called. -

    -

    - When using writable.cork() and - writable.uncork() to manage the buffering of - writes to a stream, defer calls to - writable.uncork() using - process.nextTick(). Doing so allows batching - of all writable.write() calls that occur - within a given Node.js event loop phase. -

    -
    stream.cork();
    stream.write('some ');
    stream.write('data ');
    process.nextTick(() => stream.uncork()); -
    - -

    - If the writable.cork() method is called - multiple times on a stream, the same number of calls to - writable.uncork() must be called to flush the - buffered data. -

    -
    stream.cork();
    stream.write('some ');
    stream.cork();
    stream.write('data ');
    process.nextTick(() => {
    stream.uncork();
    // The data will not be flushed until uncork() is called a second time.
    stream.uncork();
    }); -
    - -

    See also: writable.cork().

    -
    -

    - Returns void -

    -
    -
    - -

    v0.11.2

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - The writable.write() method writes some data - to the stream, and calls the supplied - callback once the data has been fully - handled. If an error occurs, the - callback will be called with the error as its - first argument. The callback is called - asynchronously and before 'error' is emitted. -

    -

    - The return value is true if the internal - buffer is less than the - highWaterMark configured when the stream was - created after admitting chunk. If - false is returned, further attempts to write - data to the stream should stop until the - 'drain' event is emitted. -

    -

    - While a stream is not draining, calls to - write() will buffer chunk, and - return false. Once all currently buffered chunks are - drained (accepted for delivery by the operating system), - the 'drain' event will be emitted. Once - write() returns false, do not write more - chunks until the 'drain' event is emitted. - While calling write() on a stream that is not - draining is allowed, Node.js will buffer all written - chunks until maximum memory usage occurs, at which point - it will abort unconditionally. Even before it aborts, high - memory usage will cause poor garbage collector performance - and high RSS (which is not typically released back to the - system, even after the memory is no longer required). - Since TCP sockets may never drain if the remote peer does - not read the data, writing a socket that is not draining - may lead to a remotely exploitable vulnerability. -

    -

    - Writing data while the stream is not draining is - particularly problematic for a Transform, - because the Transform streams are paused by - default until they are piped or a 'data' or - 'readable' event handler is added. -

    -

    - If the data to be written can be generated or fetched on - demand, it is recommended to encapsulate the logic into a - Readable and use - pipe. However, if calling write() is preferred, - it is possible to respect backpressure and avoid memory - issues using the 'drain' event: -

    -
    function write(data, cb) {
    if (!stream.write(data)) {
    stream.once('drain', cb);
    } else {
    process.nextTick(cb);
    }
    }

    // Wait for cb to be called before doing any other write.
    write('hello', () => {
    console.log('Write completed, do more writes now.');
    }); -
    - -

    - A Writable stream in object mode will always - ignore the encoding argument. -

    -
    -
    -

    Parameters

    -
      -
    • - chunk: - any -
      -

      - Optional data to write. For streams not operating in - object mode, chunk must be a {string}, - {Buffer}, {TypedArray} or {DataView}. For object - mode streams, chunk may be any - JavaScript value other than null. -

      -
      -
      -
    • -
    • - Optionalcallback: - (error: - undefined - | - null - | - Error) - => - void -
      -

      - Callback for when this chunk of data is flushed. -

      -
      -
      -
    • -
    -
    -

    - Returns boolean -

    -

    - false if the stream wishes for the calling code - to wait for the 'drain' event to be emitted - before continuing to write additional data; otherwise - true. -

    -
    -
    - -

    v0.9.4

    -
    -
    - -
  • - -
  • -
    -

    - The writable.write() method writes some data - to the stream, and calls the supplied - callback once the data has been fully - handled. If an error occurs, the - callback will be called with the error as its - first argument. The callback is called - asynchronously and before 'error' is emitted. -

    -

    - The return value is true if the internal - buffer is less than the - highWaterMark configured when the stream was - created after admitting chunk. If - false is returned, further attempts to write - data to the stream should stop until the - 'drain' event is emitted. -

    -

    - While a stream is not draining, calls to - write() will buffer chunk, and - return false. Once all currently buffered chunks are - drained (accepted for delivery by the operating system), - the 'drain' event will be emitted. Once - write() returns false, do not write more - chunks until the 'drain' event is emitted. - While calling write() on a stream that is not - draining is allowed, Node.js will buffer all written - chunks until maximum memory usage occurs, at which point - it will abort unconditionally. Even before it aborts, high - memory usage will cause poor garbage collector performance - and high RSS (which is not typically released back to the - system, even after the memory is no longer required). - Since TCP sockets may never drain if the remote peer does - not read the data, writing a socket that is not draining - may lead to a remotely exploitable vulnerability. -

    -

    - Writing data while the stream is not draining is - particularly problematic for a Transform, - because the Transform streams are paused by - default until they are piped or a 'data' or - 'readable' event handler is added. -

    -

    - If the data to be written can be generated or fetched on - demand, it is recommended to encapsulate the logic into a - Readable and use - pipe. However, if calling write() is preferred, - it is possible to respect backpressure and avoid memory - issues using the 'drain' event: -

    -
    function write(data, cb) {
    if (!stream.write(data)) {
    stream.once('drain', cb);
    } else {
    process.nextTick(cb);
    }
    }

    // Wait for cb to be called before doing any other write.
    write('hello', () => {
    console.log('Write completed, do more writes now.');
    }); -
    - -

    - A Writable stream in object mode will always - ignore the encoding argument. -

    -
    -
    -

    Parameters

    -
      -
    • - chunk: - any -
      -

      - Optional data to write. For streams not operating in - object mode, chunk must be a {string}, - {Buffer}, {TypedArray} or {DataView}. For object - mode streams, chunk may be any - JavaScript value other than null. -

      -
      -
      -
    • -
    • - encoding: - BufferEncoding -
      -

      - The encoding, if chunk is a string. -

      -
      -
      -
    • -
    • - Optionalcallback: - (error: - undefined - | - null - | - Error) - => - void -
      -

      - Callback for when this chunk of data is flushed. -

      -
      -
      -
    • -
    -
    -

    - Returns boolean -

    -

    - false if the stream wishes for the calling code - to wait for the 'drain' event to be emitted - before continuing to write additional data; otherwise - true. -

    -
    -
    - -

    v0.9.4

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Sends an HTTP/1.1 100 Continue message to the client, - indicating that the request body should be sent. See the - 'checkContinue' event on Server. -

    -
    -
    -

    Parameters

    -
      -
    • - Optionalcallback: - () - => - void -
    • -
    -
    -

    - Returns void -

    -
    -
    - -

    v0.3.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Sends an HTTP/1.1 103 Early Hints message to the client - with a Link header, indicating that the user agent can - preload/preconnect the linked resources. The - hints is an object containing the values of - headers to be sent with early hints message. The optional - callback argument will be called when the - response message has been written. -

    -

    Example

    -
    const earlyHintsLink = '</styles.css>; rel=preload; as=style';
    response.writeEarlyHints({
    'link': earlyHintsLink,
    });

    const earlyHintsLinks = [
    '</styles.css>; rel=preload; as=style',
    '</scripts.js>; rel=preload; as=script',
    ];
    response.writeEarlyHints({
    'link': earlyHintsLinks,
    'x-trace-id': 'id for diagnostics',
    });

    const earlyHintsCallback = () => console.log('early hints message sent');
    response.writeEarlyHints({
    'link': earlyHintsLinks,
    }, earlyHintsCallback); -
    -
    -
    -

    Parameters

    -
      -
    • - hints: - Record<string, - string - | - string[]> -
      -

      An object containing the values of headers

      -
      -
      -
    • -
    • - Optionalcallback: - () - => - void -
      -

      - Will be called when the response message has been - written -

      -
      -
      -
    • -
    -
    -

    - Returns void -

    -
    -
    - -

    v18.11.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Sends a response header to the request. The status code is - a 3-digit HTTP status code, like 404. The - last argument, headers, are the response - headers. Optionally one can give a human-readable - statusMessage as the second argument. -

    -

    - headers may be an Array where - the keys and values are in the same list. It is - not a list of tuples. So, the even-numbered - offsets are key values, and the odd-numbered offsets are - the associated values. The array is in the same format as - request.rawHeaders. -

    -

    - Returns a reference to the ServerResponse, so - that calls can be chained. -

    -
    const body = 'hello world';
    response
    .writeHead(200, {
    'Content-Length': Buffer.byteLength(body),
    'Content-Type': 'text/plain',
    })
    .end(body); -
    - -

    - This method must only be called once on a message and it - must be called before response.end() is - called. -

    -

    - If response.write() or - response.end() are called before calling - this, the implicit/mutable headers will be calculated and - call this function. -

    -

    - When headers have been set with - response.setHeader(), they will be merged - with any headers passed to - response.writeHead(), with the headers passed - to response.writeHead() given precedence. -

    -

    - If this method is called and - response.setHeader() has not been called, it - will directly write the supplied header values onto the - network channel without caching internally, and the - response.getHeader() on the header will not - yield the expected result. If progressive population of - headers is desired with potential future retrieval and - modification, use - response.setHeader() instead. -

    -
    // Returns content-type = text/plain
    const server = http.createServer((req, res) => {
    res.setHeader('Content-Type', 'text/html');
    res.setHeader('X-Foo', 'bar');
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('ok');
    }); -
    - -

    - Content-Length is read in bytes, not - characters. Use Buffer.byteLength() to - determine the length of the body in bytes. Node.js will - check whether Content-Length and the length - of the body which has been transmitted are equal or not. -

    -

    - Attempting to set a header field name or value that - contains invalid characters will result in a - [Error][] being thrown. -

    -
    -
    -

    Parameters

    -
      -
    • - statusCode: - number -
    • -
    • - OptionalstatusMessage: - string -
    • -
    • - Optionalheaders: - OutgoingHttpHeaders - | - OutgoingHttpHeader[] -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.1.30

    -
    -
    - -
  • - -
  • -
    -

    - Sends a response header to the request. The status code is - a 3-digit HTTP status code, like 404. The - last argument, headers, are the response - headers. Optionally one can give a human-readable - statusMessage as the second argument. -

    -

    - headers may be an Array where - the keys and values are in the same list. It is - not a list of tuples. So, the even-numbered - offsets are key values, and the odd-numbered offsets are - the associated values. The array is in the same format as - request.rawHeaders. -

    -

    - Returns a reference to the ServerResponse, so - that calls can be chained. -

    -
    const body = 'hello world';
    response
    .writeHead(200, {
    'Content-Length': Buffer.byteLength(body),
    'Content-Type': 'text/plain',
    })
    .end(body); -
    - -

    - This method must only be called once on a message and it - must be called before response.end() is - called. -

    -

    - If response.write() or - response.end() are called before calling - this, the implicit/mutable headers will be calculated and - call this function. -

    -

    - When headers have been set with - response.setHeader(), they will be merged - with any headers passed to - response.writeHead(), with the headers passed - to response.writeHead() given precedence. -

    -

    - If this method is called and - response.setHeader() has not been called, it - will directly write the supplied header values onto the - network channel without caching internally, and the - response.getHeader() on the header will not - yield the expected result. If progressive population of - headers is desired with potential future retrieval and - modification, use - response.setHeader() instead. -

    -
    // Returns content-type = text/plain
    const server = http.createServer((req, res) => {
    res.setHeader('Content-Type', 'text/html');
    res.setHeader('X-Foo', 'bar');
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('ok');
    }); -
    - -

    - Content-Length is read in bytes, not - characters. Use Buffer.byteLength() to - determine the length of the body in bytes. Node.js will - check whether Content-Length and the length - of the body which has been transmitted are equal or not. -

    -

    - Attempting to set a header field name or value that - contains invalid characters will result in a - [Error][] being thrown. -

    -
    -
    -

    Parameters

    -
      -
    • - statusCode: - number -
    • -
    • - Optionalheaders: - OutgoingHttpHeaders - | - OutgoingHttpHeader[] -
    • -
    -
    -

    - Returns this -

    -
    -
    - -

    v0.1.30

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Sends a HTTP/1.1 102 Processing message to the client, - indicating that the request body should be sent. -

    -
    -

    - Returns void -

    -
    -
    - -

    v10.0.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • - Experimental -
    -

    - Listens once to the abort event on the - provided signal. -

    -

    - Listening to the abort event on abort signals - is unsafe and may lead to resource leaks since another - third party with the signal can call - e.stopImmediatePropagation(). Unfortunately - Node.js cannot change this since it would violate the web - standard. Additionally, the original API makes it easy to - forget to remove listeners. -

    -

    - This API allows safely using AbortSignals in - Node.js APIs by solving these two issues by listening to - the event such that - stopImmediatePropagation does not prevent the - listener from running. -

    -

    - Returns a disposable so that it may be unsubscribed from - more easily. -

    -
    import { addAbortListener } from 'node:events';

    function example(signal) {
    let disposable;
    try {
    signal.addEventListener('abort', (e) => e.stopImmediatePropagation());
    disposable = addAbortListener(signal, (e) => {
    // Do something when signal is aborted.
    });
    } finally {
    disposable?.[Symbol.dispose]();
    }
    } -
    -
    -
    -

    Parameters

    -
      -
    • - signal: - AbortSignal -
    • -
    • - resource: - (event: - Event) - => - void -
    • -
    -
    -

    - Returns Disposable -

    -

    - Disposable that removes the abort listener. -

    -
    -
    - -

    v20.5.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • - Experimental -
    -

    - A utility method for creating a Writable from - a web WritableStream. -

    -
    -
    -

    Parameters

    -
      -
    • - writableStream: - WritableStream -
    • -
    • - Optionaloptions: - Pick<
          WritableOptions,
          "signal"
          | - "highWaterMark"
          | - "objectMode"
          | - "decodeStrings",
      >
      -
    • -
    -
    -

    - Returns Writable -

    -
    -
    - -

    v17.0.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Returns a copy of the array of listeners for the event - named eventName. -

    -

    - For EventEmitters this behaves exactly the - same as calling .listeners on the emitter. -

    -

    - For EventTargets this is the only way to get - the event listeners for the event target. This is useful - for debugging and diagnostic purposes. -

    -
    import { getEventListeners, EventEmitter } from 'node:events';

    {
    const ee = new EventEmitter();
    const listener = () => console.log('Events are fun');
    ee.on('foo', listener);
    console.log(getEventListeners(ee, 'foo')); // [ [Function: listener] ]
    }
    {
    const et = new EventTarget();
    const listener = () => console.log('Events are fun');
    et.addEventListener('foo', listener);
    console.log(getEventListeners(et, 'foo')); // [ [Function: listener] ]
    } -
    -
    -
    -

    Parameters

    -
      -
    • - emitter: - EventTarget - | - EventEmitter -
    • -
    • - name: - string - | - symbol -
    • -
    -
    -

    - Returns Function[] -

    -
    -
    - -

    v15.2.0, v14.17.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    Returns the currently set max amount of listeners.

    -

    - For EventEmitters this behaves exactly the - same as calling .getMaxListeners on the - emitter. -

    -

    - For EventTargets this is the only way to get - the max event listeners for the event target. If the - number of event handlers on a single EventTarget exceeds - the max set, the EventTarget will print a warning. -

    -
    import { getMaxListeners, setMaxListeners, EventEmitter } from 'node:events';

    {
    const ee = new EventEmitter();
    console.log(getMaxListeners(ee)); // 10
    setMaxListeners(11, ee);
    console.log(getMaxListeners(ee)); // 11
    }
    {
    const et = new EventTarget();
    console.log(getMaxListeners(et)); // 10
    setMaxListeners(11, et);
    console.log(getMaxListeners(et)); // 11
    } -
    -
    -
    -

    Parameters

    -
      -
    • - emitter: - EventTarget - | - EventEmitter -
    • -
    -
    -

    - Returns number -

    -
    -
    - -

    v19.9.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - A class method that returns the number of listeners for - the given eventName registered on the given - emitter. -

    -
    import { EventEmitter, listenerCount } from 'node:events';

    const myEmitter = new EventEmitter();
    myEmitter.on('event', () => {});
    myEmitter.on('event', () => {});
    console.log(listenerCount(myEmitter, 'event'));
    // Prints: 2 -
    -
    -
    -

    Parameters

    -
      -
    • - emitter: - EventEmitter -
      -

      The emitter to query

      -
      -
      -
    • -
    • - eventName: - string - | - symbol -
      -

      The event name

      -
      -
      -
    • -
    -
    -

    - Returns number -

    -
    -
    - -

    v0.9.12

    -
    -
    - -

    - Since v3.2.0 - Use listenerCount instead. -

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -
    import { on, EventEmitter } from 'node:events';
    import process from 'node:process';

    const ee = new EventEmitter();

    // Emit later on
    process.nextTick(() => {
    ee.emit('foo', 'bar');
    ee.emit('foo', 42);
    });

    for await (const event of on(ee, 'foo')) {
    // The execution of this inner block is synchronous and it
    // processes one event at a time (even with await). Do not use
    // if concurrent execution is required.
    console.log(event); // prints ['bar'] [42]
    }
    // Unreachable here -
    - -

    - Returns an AsyncIterator that iterates - eventName events. It will throw if the - EventEmitter emits 'error'. It - removes all listeners when exiting the loop. The - value returned by each iteration is an array - composed of the emitted event arguments. -

    -

    - An AbortSignal can be used to cancel waiting - on events: -

    -
    import { on, EventEmitter } from 'node:events';
    import process from 'node:process';

    const ac = new AbortController();

    (async () => {
    const ee = new EventEmitter();

    // Emit later on
    process.nextTick(() => {
    ee.emit('foo', 'bar');
    ee.emit('foo', 42);
    });

    for await (const event of on(ee, 'foo', { signal: ac.signal })) {
    // The execution of this inner block is synchronous and it
    // processes one event at a time (even with await). Do not use
    // if concurrent execution is required.
    console.log(event); // prints ['bar'] [42]
    }
    // Unreachable here
    })();

    process.nextTick(() => ac.abort()); -
    - -

    - Use the close option to specify an array of - event names that will end the iteration: -

    -
    import { on, EventEmitter } from 'node:events';
    import process from 'node:process';

    const ee = new EventEmitter();

    // Emit later on
    process.nextTick(() => {
    ee.emit('foo', 'bar');
    ee.emit('foo', 42);
    ee.emit('close');
    });

    for await (const event of on(ee, 'foo', { close: ['close'] })) {
    console.log(event); // prints ['bar'] [42]
    }
    // the loop will exit after 'close' is emitted
    console.log('done'); // prints 'done' -
    -
    -
    -

    Parameters

    -
      -
    • - emitter: - EventEmitter -
    • -
    • - eventName: - string - | - symbol -
    • -
    • - Optionaloptions: - StaticEventEmitterIteratorOptions -
    • -
    -
    -

    - Returns - AsyncIterableIterator<any[]> -

    -

    - An AsyncIterator that iterates - eventName events emitted by the - emitter -

    -
    -
    - -

    v13.6.0, v12.16.0

    -
    -
    - -
  • - -
  • -
    -
    import { on, EventEmitter } from 'node:events';
    import process from 'node:process';

    const ee = new EventEmitter();

    // Emit later on
    process.nextTick(() => {
    ee.emit('foo', 'bar');
    ee.emit('foo', 42);
    });

    for await (const event of on(ee, 'foo')) {
    // The execution of this inner block is synchronous and it
    // processes one event at a time (even with await). Do not use
    // if concurrent execution is required.
    console.log(event); // prints ['bar'] [42]
    }
    // Unreachable here -
    - -

    - Returns an AsyncIterator that iterates - eventName events. It will throw if the - EventEmitter emits 'error'. It - removes all listeners when exiting the loop. The - value returned by each iteration is an array - composed of the emitted event arguments. -

    -

    - An AbortSignal can be used to cancel waiting - on events: -

    -
    import { on, EventEmitter } from 'node:events';
    import process from 'node:process';

    const ac = new AbortController();

    (async () => {
    const ee = new EventEmitter();

    // Emit later on
    process.nextTick(() => {
    ee.emit('foo', 'bar');
    ee.emit('foo', 42);
    });

    for await (const event of on(ee, 'foo', { signal: ac.signal })) {
    // The execution of this inner block is synchronous and it
    // processes one event at a time (even with await). Do not use
    // if concurrent execution is required.
    console.log(event); // prints ['bar'] [42]
    }
    // Unreachable here
    })();

    process.nextTick(() => ac.abort()); -
    - -

    - Use the close option to specify an array of - event names that will end the iteration: -

    -
    import { on, EventEmitter } from 'node:events';
    import process from 'node:process';

    const ee = new EventEmitter();

    // Emit later on
    process.nextTick(() => {
    ee.emit('foo', 'bar');
    ee.emit('foo', 42);
    ee.emit('close');
    });

    for await (const event of on(ee, 'foo', { close: ['close'] })) {
    console.log(event); // prints ['bar'] [42]
    }
    // the loop will exit after 'close' is emitted
    console.log('done'); // prints 'done' -
    -
    -
    -

    Parameters

    -
      -
    • - emitter: - EventTarget -
    • -
    • - eventName: - string -
    • -
    • - Optionaloptions: - StaticEventEmitterIteratorOptions -
    • -
    -
    -

    - Returns - AsyncIterableIterator<any[]> -

    -

    - An AsyncIterator that iterates - eventName events emitted by the - emitter -

    -
    -
    - -

    v13.6.0, v12.16.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    - Creates a Promise that is fulfilled when the - EventEmitter emits the given event or that is - rejected if the EventEmitter emits - 'error' while waiting. The - Promise will resolve with an array of all the - arguments emitted to the given event. -

    -

    - This method is intentionally generic and works with the - web platform - EventTarget - interface, which has no special'error' event - semantics and does not listen to the - 'error' event. -

    -
    import { once, EventEmitter } from 'node:events';
    import process from 'node:process';

    const ee = new EventEmitter();

    process.nextTick(() => {
    ee.emit('myevent', 42);
    });

    const [value] = await once(ee, 'myevent');
    console.log(value);

    const err = new Error('kaboom');
    process.nextTick(() => {
    ee.emit('error', err);
    });

    try {
    await once(ee, 'myevent');
    } catch (err) {
    console.error('error happened', err);
    } -
    - -

    - The special handling of the 'error' event is - only used when events.once() is used to wait - for another event. If events.once() is used - to wait for the 'error' event itself, then it - is treated as any other kind of event without special - handling: -

    -
    import { EventEmitter, once } from 'node:events';

    const ee = new EventEmitter();

    once(ee, 'error')
    .then(([err]) => console.log('ok', err.message))
    .catch((err) => console.error('error', err.message));

    ee.emit('error', new Error('boom'));

    // Prints: ok boom -
    - -

    - An AbortSignal can be used to cancel waiting - for the event: -

    -
    import { EventEmitter, once } from 'node:events';

    const ee = new EventEmitter();
    const ac = new AbortController();

    async function foo(emitter, event, signal) {
    try {
    await once(emitter, event, { signal });
    console.log('event emitted!');
    } catch (error) {
    if (error.name === 'AbortError') {
    console.error('Waiting for the event was canceled!');
    } else {
    console.error('There was an error', error.message);
    }
    }
    }

    foo(ee, 'foo', ac.signal);
    ac.abort(); // Abort waiting for the event
    ee.emit('foo'); // Prints: Waiting for the event was canceled! -
    -
    -
    -

    Parameters

    -
      -
    • - emitter: - EventEmitter -
    • -
    • - eventName: - string - | - symbol -
    • -
    • - Optionaloptions: - StaticEventEmitterOptions -
    • -
    -
    -

    - Returns Promise<any[]> -

    -
    -
    - -

    v11.13.0, v10.16.0

    -
    -
    - -
  • - -
  • -
    -

    - Creates a Promise that is fulfilled when the - EventEmitter emits the given event or that is - rejected if the EventEmitter emits - 'error' while waiting. The - Promise will resolve with an array of all the - arguments emitted to the given event. -

    -

    - This method is intentionally generic and works with the - web platform - EventTarget - interface, which has no special'error' event - semantics and does not listen to the - 'error' event. -

    -
    import { once, EventEmitter } from 'node:events';
    import process from 'node:process';

    const ee = new EventEmitter();

    process.nextTick(() => {
    ee.emit('myevent', 42);
    });

    const [value] = await once(ee, 'myevent');
    console.log(value);

    const err = new Error('kaboom');
    process.nextTick(() => {
    ee.emit('error', err);
    });

    try {
    await once(ee, 'myevent');
    } catch (err) {
    console.error('error happened', err);
    } -
    - -

    - The special handling of the 'error' event is - only used when events.once() is used to wait - for another event. If events.once() is used - to wait for the 'error' event itself, then it - is treated as any other kind of event without special - handling: -

    -
    import { EventEmitter, once } from 'node:events';

    const ee = new EventEmitter();

    once(ee, 'error')
    .then(([err]) => console.log('ok', err.message))
    .catch((err) => console.error('error', err.message));

    ee.emit('error', new Error('boom'));

    // Prints: ok boom -
    - -

    - An AbortSignal can be used to cancel waiting - for the event: -

    -
    import { EventEmitter, once } from 'node:events';

    const ee = new EventEmitter();
    const ac = new AbortController();

    async function foo(emitter, event, signal) {
    try {
    await once(emitter, event, { signal });
    console.log('event emitted!');
    } catch (error) {
    if (error.name === 'AbortError') {
    console.error('Waiting for the event was canceled!');
    } else {
    console.error('There was an error', error.message);
    }
    }
    }

    foo(ee, 'foo', ac.signal);
    ac.abort(); // Abort waiting for the event
    ee.emit('foo'); // Prints: Waiting for the event was canceled! -
    -
    -
    -

    Parameters

    -
      -
    • - emitter: - EventTarget -
    • -
    • - eventName: - string -
    • -
    • - Optionaloptions: - StaticEventEmitterOptions -
    • -
    -
    -

    - Returns Promise<any[]> -

    -
    -
    - -

    v11.13.0, v10.16.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • -
    -
    import { setMaxListeners, EventEmitter } from 'node:events';

    const target = new EventTarget();
    const emitter = new EventEmitter();

    setMaxListeners(5, target, emitter); -
    -
    -
    -

    Parameters

    -
      -
    • - Optionaln: - number -
      -

      - A non-negative number. The maximum number of - listeners per EventTarget event. -

      -
      -
      -
    • -
    • - ...eventTargets: - (EventTarget - | - EventEmitter)[] -
    • -
    -
    -

    - Returns void -

    -
    -
    - -

    v15.4.0

    -
    -
    - -
  • -
-
-
- - -
    - -
  • - Experimental -
    -

    - A utility method for creating a web - WritableStream from a Writable. -

    -
    -
    -

    Parameters

    -
      -
    • - streamWritable: - Writable -
    • -
    -
    -

    - Returns - WritableStream -

    -
    -
    - -

    v17.0.0

    -
    -
    - -
  • -
-
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/classes/ContentEncoding.html b/packages/binden/docs/classes/ContentEncoding.html deleted file mode 100644 index c950fd0a..00000000 --- a/packages/binden/docs/classes/ContentEncoding.html +++ /dev/null @@ -1,753 +0,0 @@ - - - - - - ContentEncoding | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Class ContentEncoding

-
-
-

- Hierarchy (View Summary) -

- -
-
-

Implements

- -
- -
-
-
- - - -
-
-

Constructors

- -
-
-

Accessors

- -
-
-

Methods

- -
-
-
-
-
-
- -

- - - - Constructors -

-
-
-
- - - -
-
-
-
- -

- - - - Accessors -

-
-
-
- - - -
-
- - -
    -
  • - get - q_value(): - null - | - number -
  • -
  • -

    - Returns null - | - number -

    - -
  • -
-
-
-
-
- -

- - - - Methods -

-
-
-
- - - -
-
- - - -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/classes/ContentRange.html b/packages/binden/docs/classes/ContentRange.html deleted file mode 100644 index 3ebd2daa..00000000 --- a/packages/binden/docs/classes/ContentRange.html +++ /dev/null @@ -1,849 +0,0 @@ - - - - - - ContentRange | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Class ContentRange

-
-
-

- Hierarchy (View Summary) -

-
    -
  • - Range -
      -
    • - ContentRange -
    • -
    -
  • -
-
-
-

Implements

- -
- -
-
-
- - - -
-
-

Constructors

- -
-
-

Accessors

- -
-
-

Methods

- -
-
-
-
-
-
- -

- - - - Constructors -

-
-
-
- - - -
-
-
-
- -

- - - - Accessors -

-
-
-
- - -
    -
  • - get - end(): - null - | - number -
  • -
  • -

    - Returns null - | - number -

    - -
  • -
-
-
- - - -
-
- - -
    -
  • - get - start(): - null - | - number -
  • -
  • -

    - Returns null - | - number -

    - -
  • -
-
-
- - -
    -
  • - get - unit(): - "bytes" -
  • -
  • -

    - Returns - "bytes" -

    - -
  • -
-
-
-
-
- -

- - - - Methods -

-
-
-
- - - -
-
- - - -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/classes/ContentType.html b/packages/binden/docs/classes/ContentType.html deleted file mode 100644 index 3f777671..00000000 --- a/packages/binden/docs/classes/ContentType.html +++ /dev/null @@ -1,752 +0,0 @@ - - - - - - ContentType | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Class ContentType

-
-
-

Implements

- -
- -
-
-
- - - -
-
-

Constructors

- -
-
-

Accessors

- -
-
-

Methods

- -
-
-
-
-
-
- -

- - - - Constructors -

-
-
-
- - - -
-
-
-
- -

- - - - Accessors -

-
-
-
- - - -
-
- - - -
-
- - - -
-
-
-
- -

- - - - Methods -

-
-
-
- - -
    - -
  • -
    -

    Returns a string representation of an object.

    -
    -

    - Returns string -

    -
    - -
  • -
-
-
- - - -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/classes/Context.html b/packages/binden/docs/classes/Context.html deleted file mode 100644 index 6986cb5a..00000000 --- a/packages/binden/docs/classes/Context.html +++ /dev/null @@ -1,1556 +0,0 @@ - - - - - - Context | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Class Context

-
-
-

Implements

- -
- -
-
-
- - - -
-
-

Constructors

- -
-
-

Accessors

- -
-
-

Methods

- -
-
-
-
-
-
- -

- - - - Constructors -

-
-
-
- - - -
-
-
-
- -

- - - - Accessors -

-
-
-
- - -
    -
  • - get - done(): - boolean -
  • -
  • -

    - Returns boolean -

    - -
  • -
  • - set - done(done: - unknown): - void -
  • -
  • -
    -

    Parameters

    -
      -
    • - done: - unknown -
    • -
    -
    -

    - Returns void -

    - -
  • -
-
-
- - -
    -
  • - get - id(): - string -
  • -
  • -

    - Returns string -

    - -
  • -
-
-
- - -
    -
  • - get - log(): - Logger -
  • -
  • -

    - Returns Logger -

    - -
  • -
-
-
- - - -
-
- - - -
-
- - -
    -
  • - get - url(): - URL -
  • -
  • -

    - Returns URL -

    - -
  • -
-
-
-
-
- -

- - - - Methods -

-
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - data: - URLSearchParams -
    • -
    -
    -

    - Returns Promise<void> -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - data: - string -
    • -
    -
    -

    - Returns Promise<void> -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - data: - unknown[] - | - Record<string, - unknown> -
    • -
    • - Optionalstringify: - (input: - unknown[] - | - Record<string, - unknown>) - => - string -
    • -
    -
    -

    - Returns Promise<void> -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - Optionaldata: - string - | - number - | - bigint - | - Readable - | - Buffer -
    • -
    -
    -

    - Returns Promise<void> -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    Send a file

    -
    -
    -

    Parameters

    -
      -
    • - url: - string - | - URL -
    • -
    -
    -

    - Returns Promise<void> -

    -
    - -
  • -
-
-
- - - -
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - code: - number -
    • -
    -
    -

    - Returns this -

    - -
  • -
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - data: - string -
    • -
    -
    -

    - Returns Promise<void> -

    - -
  • -
-
-
- - - -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/classes/Cookie.html b/packages/binden/docs/classes/Cookie.html deleted file mode 100644 index 09638d70..00000000 --- a/packages/binden/docs/classes/Cookie.html +++ /dev/null @@ -1,1085 +0,0 @@ - - - - - - Cookie | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Class Cookie

-
-
-

Implements

- -
- -
-
-
- - - -
-
-

Constructors

- -
-
-

Accessors

- -
-
-

Methods

- -
-
-
-
-
-
- -

- - - - Constructors -

-
-
-
- - - -
-
-
-
- -

- - - - Accessors -

-
-
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - -
    -
  • - get - same_site(): - "Lax" - | - "None" - | - "Strict" -
  • -
  • -

    - Returns - "Lax" - | - "None" - | - "Strict" -

    - -
  • -
-
-
- - - -
-
- - - -
-
-
-
- -

- - - - Methods -

-
-
-
- - -
    - -
  • -
    -

    Returns a string representation of an object.

    -
    -

    - Returns string -

    -
    - -
  • -
-
-
- - - -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/classes/Forwarded.html b/packages/binden/docs/classes/Forwarded.html deleted file mode 100644 index 31aee04c..00000000 --- a/packages/binden/docs/classes/Forwarded.html +++ /dev/null @@ -1,857 +0,0 @@ - - - - - - Forwarded | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Class Forwarded

-
-
-

Implements

- -
- -
-
-
- - - -
-
-

Constructors

- -
-
-

Accessors

- -
-
-

Methods

- -
-
-
-
-
-
- -

- - - - Constructors -

-
-
-
- - - -
-
-
-
- -

- - - - Accessors -

-
-
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
-
-
- -

- - - - Methods -

-
-
-
- - -
    - -
  • -
    -

    Returns a string representation of an object.

    -
    -

    - Returns string -

    -
    - -
  • -
-
-
- - - -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/classes/IfModifiedSince.html b/packages/binden/docs/classes/IfModifiedSince.html deleted file mode 100644 index 8850632a..00000000 --- a/packages/binden/docs/classes/IfModifiedSince.html +++ /dev/null @@ -1,644 +0,0 @@ - - - - - - IfModifiedSince | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Class IfModifiedSince

-
-
-

Implements

- -
- -
-
-
- - - -
-
-

Constructors

- -
-
-

Accessors

-
- - date -
-
-
-

Methods

- -
-
-
-
-
-
- -

- - - - Constructors -

-
-
-
- - - -
-
-
-
- -

- - - - Accessors -

-
-
-
- - - -
-
-
-
- -

- - - - Methods -

-
-
-
- - - -
-
- - - -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/classes/Middleware.html b/packages/binden/docs/classes/Middleware.html deleted file mode 100644 index 26b1cfe3..00000000 --- a/packages/binden/docs/classes/Middleware.html +++ /dev/null @@ -1,588 +0,0 @@ - - - - - - Middleware | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Class MiddlewareAbstract

-
- -
-
-
- - - -
-
-

Constructors

- -
-
-

Properties

- -
-
-

Methods

-
- - run -
-
-
-
-
-
-
- -

- - - - Constructors -

-
-
-
- - - -
-
-
-
- -

- - - - Properties -

-
-
-
- - -
- disabled: - boolean -
- -
-
- - -
- ignore_errors: - boolean -
- -
-
-
-
- -

- - - - Methods -

-
-
-
- - -
    - -
  • -
    -

    Parameters

    - -
    -

    - Returns - IMiddlewareReturnType -

    - -
  • -
-
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/classes/Range.html b/packages/binden/docs/classes/Range.html deleted file mode 100644 index 6521dcad..00000000 --- a/packages/binden/docs/classes/Range.html +++ /dev/null @@ -1,753 +0,0 @@ - - - - - - Range | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Class Range

-
-
-

- Hierarchy (View Summary) -

- -
-
-

Implements

- -
- -
-
-
- - - -
-
-

Constructors

- -
-
-

Accessors

- -
-
-

Methods

- -
-
-
-
-
-
- -

- - - - Constructors -

-
-
-
- - - -
-
-
-
- -

- - - - Accessors -

-
-
-
- - -
    -
  • - get - end(): - null - | - number -
  • -
  • -

    - Returns null - | - number -

    - -
  • -
-
-
- - -
    -
  • - get - start(): - null - | - number -
  • -
  • -

    - Returns null - | - number -

    - -
  • -
-
-
- - - -
-
-
-
- -

- - - - Methods -

-
-
-
- - -
    - -
  • -
    -

    Returns a string representation of an object.

    -
    -

    - Returns string -

    -
    - -
  • -
-
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - Optionalinput: - string -
    • -
    -
    -

    - Returns - Range[] -

    - -
  • -
-
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/classes/Router.html b/packages/binden/docs/classes/Router.html deleted file mode 100644 index c06c5d9c..00000000 --- a/packages/binden/docs/classes/Router.html +++ /dev/null @@ -1,1438 +0,0 @@ - - - - - - Router | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Class Router

-
- -
-
-
- - - -
-
-

Constructors

- -
-
-

Accessors

- -
-
-

Methods

- -
-
-
-
-
-
- -

- - - - Constructors -

-
-
-
- - - -
-
-
-
- -

- - - - Accessors -

-
-
-
- - -
    -
  • - get - guarded(): - boolean -
  • -
  • -

    - Returns boolean -

    - -
  • -
  • - set - guarded(guarded: - unknown): - void -
  • -
  • -
    -

    Parameters

    -
      -
    • - guarded: - unknown -
    • -
    -
    -

    - Returns void -

    - -
  • -
-
-
- - -
    -
  • - get - methods(): - Set<string> -
  • -
  • -
    -

    Get all supported methods by the router

    -
    -

    - Returns Set<string> -

    -
    - -
  • -
-
-
- - - -
-
-
-
- -

- - - - Methods -

-
-
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - -
    - -
  • -
    -

    Parameters

    -
      -
    • - method: - string -
    • -
    • - ..._middlewares: - IMiddleware[] -
    • -
    -
    -

    - Returns this -

    - -
  • -
-
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/hierarchy.html b/packages/binden/docs/hierarchy.html deleted file mode 100644 index 8193e334..00000000 --- a/packages/binden/docs/hierarchy.html +++ /dev/null @@ -1,418 +0,0 @@ - - - - - - binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-

binden

-

Hierarchy Summary

- - - - - - - - - -
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/index.html b/packages/binden/docs/index.html deleted file mode 100644 index 31d1e79d..00000000 --- a/packages/binden/docs/index.html +++ /dev/null @@ -1,895 +0,0 @@ - - - - - - binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-

binden

-
- -

- Binden - CI Status - version - Known Vulnerabilities - Coverage Status - code style: prettier - Contributor Covenant - semantic-release - Conventional Commits - GitHub top language - node version - npm downloads - License - -

-

- A simple server framework (written in - TypeScript). -

- - -
npm install binden
-
- - - - - -
    -
  • - .use() - Add a Middleware/Router to the - stack -
  • -
-
import { Binden } from "binden";

const app = new Binden().use(middleware1).use(router2);
app.use("/path", middleware2, router2);
app.use(new RegExp("path"), router3, middleware1);
const middleware3 = (context) => context.json({ message: "Hello World!" });
app.use("/path2", middleware3); -
- -
    -
  • - .off() - remove a Middleware/Router form - the stack -
  • -
-
import { Binden } from "binden";

const app = new Binden()
.use("/path", middleware1)
.use(middleware2)
.off("/path", middleware1); -
- -
    -
  • .createServer() - create a server (HTTP)
  • -
-
import { Binden } from "binden";

const app = new Binden()
.use(new RegExp("path"), middleware)
.use("/path2", router);
const server = app.createServer(); -
- -
    -
  • - .createSecureServer() - create a server (HTTPS) -
  • -
-
import { Binden } from "binden";

const app = new Binden().use("/path", middleware).use("/path2", router);
const secureServer = app.createSecureServer({ key, cert }); -
- - - - -
const { log } = context;
log.info("Hello World", { data: 100 }); -
- -
    -
  • .setHeader() - Set a response header
  • -
-
const name = "X-HEADER";
const value = ["value1", "value2"];
context.setHeader(name, value); -
- -
    -
  • .status() - set the response status
  • -
-
context.status(401);
-
- -
    -
  • - .request - get the original request object (instanceof BindenRequest) -
  • -
-
const { request } = context;
-
- -
    -
  • - .response - get the original response object (instanceof BindenResponse) -
  • -
-
const { response } = context;
-
- -
    -
  • - .id - get id of the context (generated - by the - randomUUID() - function and logged as trace_id by the - context.log) -
  • -
-
const { id } = context;
-
- -
    -
  • .done
  • -
-
class MyMiddleware extends Middleware {
public run(context: Context): void {
context.done = true; // Stop passing the `context` to other middlewares
}
} -
- -

or with a function

-
const MyMiddleware = (context): void => {
context.done = true;
}; -
- -
    -
  • .url - parsed URL object
  • -
-
const {
log,
url: { search },
} = context;
log.trace("url search string", { search }); -
- -
    -
  • - .send() - execute - context.response.send() and set - context.done to true -
  • -
-
await context.send(data);
// or
await context.response.send(data);
context.done = true; -
- -
    -
  • - .json() - execute - context.response.json() and set - context.done to true -
  • -
-
const json = { message: "Hello World!" };
await context.json(json);
// or
await context.response.json(json);
context.done = true; -
- -

- A custom stringify function (e.g. - fast-json-stringify) can pan passed as the second argument. -

-
const json = { currency: "💶", value: 120 };
const fastJSON = await import("fast-json-stringify");
const stringify = fastJSON({
title: "Example Schema",
type: "object",
properties: {
currency: {
type: "string",
},
value: {
type: "integer",
},
},
required: ["currency", "value"],
additionalProperties: false,
});
const json = { currency: "💶", value: 120 };
await context.json(json, stringify);
// or using `BindenResponse`
await context.response.json(json, stringify);
context.done = true; -
- -
    -
  • - .text() - execute - context.response.text() and set - context.done to true -
  • -
-
const text = "Hello World!";
await context.text(text);
// or
await context.response.text(text);
context.done = true; -
- -
    -
  • - .html() - execute - context.response.html() and set - context.done to true -
  • -
-
const html = "<html></html>";
await context.html(html);
// or
await context.response.html(html);
context.done = true; -
- -
    -
  • - .form() - execute - context.response.form() and set - context.done to true -
  • -
-
const form = new URLSearchParams({ a: "1" });
await context.form(form);
// or
await context.response.form(form);
context.done = true; -
- -
    -
  • - .sendFile() - execute - context.response.sendFile() and set - context.done to true -
  • -
-
const path = "<path to file>";
await context.sendFile(path);
// or
await context.response.sendFile(path);
context.done = true; -
- -
    -
  • .throw() - throw BindenError
  • -
-
context.throw(402, { json: { error: "Payment Required" }, expose: true });
-
- - - -

- Any middleware should be extended from the abstract - Middleware class and implement the - .run() method -

-
import { randomInt } from "crypto";
import { Middleware, Context } from "binden";

export class MyMiddleware extends Middleware {
public async run(context: Context): Promise<void> {
const randomNumber = await new Promise((resolve, reject) => {
randomInt(1, 100, (error, n) => {
if (error) {
reject(error);
} else {
resolve(n);
}
});
});

if (randomNumber <= 50) {
context.throw(400, {
message: "Generated number is less than or equal to 50",
expose: true,
});
}

return context.json({ message: "Generated number is greater than 50" });
}
} -
- -
    -
  • - .disabled - One can disable a middleware at any time -
  • -
-
import { Middleware, Context } from "binden";

export class MyMiddleware1 extends Middleware {
public run(context: Context): Promise<void> {
return context.json({ message: "Hello World" });
}
}

const mm1 = new MyMiddleware1({ disabled: true });

export class MyMiddleware2 extends Middleware {
public async run(): Promise<void> {
// Disable `mm1` every hour
setInterval(
() => {
mm1.disabled = !mm1.disabled;
},
1000 * 60 * 60,
);
}
} -
- -
    -
  • - .ignore_errors - ignore errors from - await this.run(context) -
  • -
-
import { Middleware, Context } from "binden";

export class MyMiddleware1 extends Middleware {
public run(context: Context): Promise<void> {
if (this.ignore_errors) {
return context.json({ message: "Hello World" });
}
context.throw(400);
}
}

const mm1 = new MyMiddleware1({ ignore_errors: true });

export class MyMiddleware2 extends Middleware {
public async run(): Promise<void> {
// Throw errors from `mm1.run()` every minute
setInterval(() => {
mm1.ignore_errors = !mm1.ignore_errors;
}, 1000 * 60);
}
} -
- - - -

BindenError represents an HTTP error

-
import { Middleware, Context, BindenError } from "binden";

export class MyMiddleware extends Middleware {
public run(context: Context): Promise<void> {
const { length } = context.request.cookies;

if (!length) {
const status = 401;
const expose = true;
const message =
"Text message to send (when `expose === true` and `json === null`)";
const json = {
error:
"Send `json` as application/json (when `expose === true`) instead of `message`",
};
throw new BindenError(status, { expose, message, json });
}

try {
await validateBody();
} catch (cause) {
const message = "Invalid body";
const expose = true;
throw new BindenError(400, { expose, message, json: { message }, cause });
}

return context.json({ message: `Received ${length} cookies` });
}
} -
- - - -

Simple usage with http

-
import { createServer } from "http";
import { BindenRequest } from "binden";
server = createServer({ IncomingMessage: BindenRequest }); -
- -
    -
  • .header() - get a header by name
  • -
-
const rawHeader = request.header("X-Header");
-
- -
    -
  • .id - get the request id
  • -
-
const { id } = request;
-
- -
    -
  • - .protocol - respects the - Forwarded - header: -
  • -
-
const { protocol } = request;
if (protocol !== "https:") {
console.error("The connection is not secure");
} -
- -
    -
  • - .secure - same as - request.protocol === "https:" -
  • -
-
const { secure } = request;
if (!secure) {
console.error("The connection is not secure");
} -
- -
    -
  • - .query - A copy of URL.search parsed by - the querystring.parse() method -
  • -
-
const { query } = request;
// same as
const query = { ...parse(this.URL.search.substring(1)) }; -
- - - -

Simple usage with http

-
import { createServer } from "http";
import { BindenResponse } from "binden";
server = createServer({ ServerResponse: BindenResponse }); -
- -
    -
  • - .cookies - The .send() method will add - cookies to the response -
  • -
-
import { randomUUID } from "crypto";
import { Cookie } from "binden";

const key = "__Secure-Random-UUID";
const value = randomUUID();
const cookie = new Cookie({ key, value });
response.cookies.add(cookie);
await response.send("Check the `Set-Cookie` header for a random UUID"); -
- -
    -
  • - .status() - Set the status code of the response -
  • -
-
await response.status(400).send();
-
- -
    -
  • .set() - Set the headers
  • -
-
const headers = {
"X-AMOUNT": "100.02 USD",
"X-MONTHS": ["jan", "feb"],
};
await response.status(402).set(headers).send("Payment is required"); -
- -
    -
  • .send() - send data
  • -
-
await response.send(
"Could be `number` | `string` | `Buffer` | `Readable` | `bigint` | `undefined`",
); -
- -
    -
  • - .json() - send an object as - application/json using JSON.stringify() -
  • -
-
const json = { k: "v", k1: 1, m: "message", f: false };
await response.json(json); -
- -

or using a custom stringify function

-
const json = { currency: "💶", value: 120 };
const fastJSON = await import("fast-json-stringify");
const stringify = fastJSON({
title: "Example Schema",
type: "object",
properties: {
currency: {
type: "string",
},
value: {
type: "integer",
},
},
required: ["currency", "value"],
additionalProperties: false,
});
await response.json(json); -
- -
    -
  • .text() - send text as plain/text
  • -
-
const text = "Hello World!";
await response.text(text); -
- -
    -
  • .html() - send text as text/html
  • -
-
const html = "<html></html>";
await response.html(html); -
- -
    -
  • .form() - send URLSearchParams;
  • -
-
const form = new URLSearchParams({ a: "1", b: ["a", "c"] });
await response.form(form); -
- - -
const path = "<path to file>";
await response.sendFile(path);
// Or with custom Stats
import { stat } from "node:fs/promises";
const stats = await stat("<PATH>");
await response.sendFile(path, stats); -
- - - - -
import { AcceptEncoding } from "binden";

const encodings = AcceptEncoding.fromString(request.headers["accept-encoding"]);
// or using BindenRequest
const { accept_encoding } = request; -
- - -
import { Authorization } from "binden";

const authorization = AcceptEncoding.fromString(
request.headers["Authorization"],
);
// or using BindenRequest
const { authorization } = request; -
- - -
import { ContentEncoding } from "binden";

const encodings = ContentEncoding.fromString(
request.headers["content-encoding"],
);
// or using BindenRequest
const { content_encoding } = request; -
- - -
import { ContentRange } from "binden";

const cr = new ContentRange({ start: 0, end: 499, size: 1000 });
response.setHeader("Content-Range", cr.toString()); -
- - -
import { ContentType } from "binden";

const type = ContentType.fromString(request.headers["content-type"]);
// or using BindenRequest
const { content_type } = request; -
- - -
import { Cookie } from "binden";

const cookies = Cookie.fromString(request.headers["cookie"]);
// or using BindenRequest
const { cookies } = request;
// or using BindenResponse
const cookie1 = new Cookie({
key: "__Secure-K1",
value: "v1",
http_only: false,
});
const cookie2 = new Cookie({
key: "K2",
value: "v2",
same_site: "None",
max_age: 1000,
});
response.cookies.add(cookie1).add(cookie2); -
- - -
import { Forwarded } from "binden";

const forwarded = Forwarded.fromString(request.headers["forwarded"]);
// or using BindenRequest
const { forwarded } = request; -
- - -
import { IfModifiedSince } from "binden";

const if_modified_since = IfModifiedSince.fromString(
request.headers["if-modified-since"],
);
// or using BindenRequest
const { if_modified_since } = request; -
- - -
import { Range } from "binden";

const range = Range.fromString(request.headers.range);
// or using BindenRequest
const { range } = request; -
- - - -
npm run test:ci
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/interfaces/IAcceptEncoding.html b/packages/binden/docs/interfaces/IAcceptEncoding.html deleted file mode 100644 index 8f7eed2c..00000000 --- a/packages/binden/docs/interfaces/IAcceptEncoding.html +++ /dev/null @@ -1,407 +0,0 @@ - - - - - - IAcceptEncoding | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Interface IAcceptEncoding

-
-
- interface - IAcceptEncoding - {
    encoding: - IAcceptEncodings;
    q_value?: - null - | - number;
} -
-
-

Implemented by

- -
- -
-
-
- - - -
-
-

Properties

- -
-
-
-
-
-
- -

- - - - Properties -

-
-
-
- - -
- encoding: - IAcceptEncodings -
- -
-
- - -
- q_value?: - null - | - number -
- -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/interfaces/IAuthorization.html b/packages/binden/docs/interfaces/IAuthorization.html deleted file mode 100644 index 543f28ce..00000000 --- a/packages/binden/docs/interfaces/IAuthorization.html +++ /dev/null @@ -1,402 +0,0 @@ - - - - - - IAuthorization | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Interface IAuthorization

-
-
- interface - IAuthorization - {
    credentials?: - null - | - string;
    type: - IAuthenticationType;
} -
-
-

Implemented by

- -
- -
-
-
- - - -
-
-

Properties

- -
-
-
-
-
-
- -

- - - - Properties -

-
-
-
- - -
- credentials?: - null - | - string -
- -
-
- - -
- type: - IAuthenticationType -
- -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/interfaces/IBaseContext.html b/packages/binden/docs/interfaces/IBaseContext.html deleted file mode 100644 index 247b6e33..00000000 --- a/packages/binden/docs/interfaces/IBaseContext.html +++ /dev/null @@ -1,417 +0,0 @@ - - - - - - IBaseContext | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Interface IBaseContext

-
-
- interface - IBaseContext - {
    request: - BindenRequest;
    response: - BindenResponse<BindenRequest>;
} -
-
-

Implemented by

- -
- -
-
-
- - - -
-
-

Properties

- -
-
-
-
-
-
- -

- - - - Properties -

-
-
-
- - -
- request: - BindenRequest -
- -
-
- - -
- response: - BindenResponse<BindenRequest> -
- -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/interfaces/IBindenErrorOptions.html b/packages/binden/docs/interfaces/IBindenErrorOptions.html deleted file mode 100644 index 444559d3..00000000 --- a/packages/binden/docs/interfaces/IBindenErrorOptions.html +++ /dev/null @@ -1,493 +0,0 @@ - - - - - - IBindenErrorOptions | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Interface IBindenErrorOptions

-
-
- interface - IBindenErrorOptions - {
    cause?: - unknown;
    expose?: - boolean;
    json?: - Record<string, - unknown>;
    message?: - string;
} -
-
-

Hierarchy

-
    -
  • - ErrorOptions -
      -
    • - IBindenErrorOptions -
    • -
    -
  • -
-
- -
-
-
- - - -
-
-

Properties

- -
-
-
-
-
-
- -

- - - - Properties -

-
-
-
- - -
- cause?: - unknown -
- -
-
- - -
- expose?: - boolean -
- -
-
- - -
- json?: - Record<string, - unknown> -
- -
-
- - -
- message?: - string -
- -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/interfaces/IBindenOptions.html b/packages/binden/docs/interfaces/IBindenOptions.html deleted file mode 100644 index 80e2dcbb..00000000 --- a/packages/binden/docs/interfaces/IBindenOptions.html +++ /dev/null @@ -1,412 +0,0 @@ - - - - - - IBindenOptions | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Interface IBindenOptions

-
-
- interface - IBindenOptions - {
    auto_head?: - boolean;
    error_handler?: - (context: - Context, - error: - unknown) - => - void;
} -
- -
-
-
- - - -
-
-

Properties

- -
-
-
-
-
-
- -

- - - - Properties -

-
-
-
- - -
- auto_head?: - boolean -
- -
-
- - -
- error_handler?: - (context: - Context, - error: - unknown) - => - void -
- -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/interfaces/IContentEncoding.html b/packages/binden/docs/interfaces/IContentEncoding.html deleted file mode 100644 index ef351f9c..00000000 --- a/packages/binden/docs/interfaces/IContentEncoding.html +++ /dev/null @@ -1,357 +0,0 @@ - - - - - - IContentEncoding | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Interface IContentEncoding

-
-
- interface - IContentEncoding - {
    encoding: - IEncodings;
} -
-
-

Implemented by

- -
- -
-
-
- - - -
-
-

Properties

- -
-
-
-
-
-
- -

- - - - Properties -

-
-
-
- - -
- encoding: - IEncodings -
- -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/interfaces/IContentRange.html b/packages/binden/docs/interfaces/IContentRange.html deleted file mode 100644 index 915c859c..00000000 --- a/packages/binden/docs/interfaces/IContentRange.html +++ /dev/null @@ -1,477 +0,0 @@ - - - - - - IContentRange | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Interface IContentRange

-
-
- interface - IContentRange - {
    end?: - null - | - number;
    size: - number - | - "*";
    start?: - null - | - number;
} -
-
-

- Hierarchy (View Summary) -

-
    -
  • - IRange -
      -
    • - IContentRange -
    • -
    -
  • -
-
-
-

Implemented by

- -
- -
-
-
- - - -
-
-

Properties

- -
-
-
-
-
-
- -

- - - - Properties -

-
-
-
- - -
- end?: - null - | - number -
- -
-
- - -
- size: - number - | - "*" -
- -
-
- - -
- start?: - null - | - number -
- -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/interfaces/IContentType.html b/packages/binden/docs/interfaces/IContentType.html deleted file mode 100644 index 053ad465..00000000 --- a/packages/binden/docs/interfaces/IContentType.html +++ /dev/null @@ -1,441 +0,0 @@ - - - - - - IContentType | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Interface IContentType

-
-
- interface - IContentType - {
    boundary?: - null - | - string;
    charset?: - null - | - string;
    type: - string;
} -
-
-

Implemented by

- -
- -
-
-
- - - -
-
-

Properties

- -
-
-
-
-
-
- -

- - - - Properties -

-
-
-
- - -
- boundary?: - null - | - string -
- -
-
- - -
- charset?: - null - | - string -
- -
-
- - -
- type: - string -
- -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/interfaces/ICookie.html b/packages/binden/docs/interfaces/ICookie.html deleted file mode 100644 index df460711..00000000 --- a/packages/binden/docs/interfaces/ICookie.html +++ /dev/null @@ -1,724 +0,0 @@ - - - - - - ICookie | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Interface ICookie

-
-
- interface - ICookie - {
    domain?: - null - | - string;
    expires?: - null - | - Date;
    http_only?: - boolean;
    key: - string;
    max_age?: - null - | - number;
    path?: - null - | - string;
    same_site?: - "Lax" - | - "None" - | - "Strict";
    secure?: - boolean;
    value: - string;
} -
-
-

Implemented by

- -
- -
-
-
- - - - -
-
-
-
- -

- - - - Properties -

-
-
-
- - -
- domain?: - null - | - string -
- -
-
- - -
- expires?: - null - | - Date -
- -
-
- - -
- http_only?: - boolean -
- -
-
- - -
- key: - string -
- -
-
- - -
- max_age?: - null - | - number -
- -
-
- - -
- path?: - null - | - string -
- -
-
- - -
- same_site?: - "Lax" - | - "None" - | - "Strict" -
- -
-
- - -
- secure?: - boolean -
- -
-
- - -
- value: - string -
- -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/interfaces/IForwarded.html b/packages/binden/docs/interfaces/IForwarded.html deleted file mode 100644 index a0d586be..00000000 --- a/packages/binden/docs/interfaces/IForwarded.html +++ /dev/null @@ -1,528 +0,0 @@ - - - - - - IForwarded | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Interface IForwarded

-
-
- interface - IForwarded - {
    by?: - null - | - string;
    for: - string;
    host?: - null - | - string;
    proto?: - null - | - string;
    secret?: - null - | - string;
} -
-
-

Implemented by

- -
- -
-
-
- - - -
-
-

Properties

- -
-
-
-
-
-
- -

- - - - Properties -

-
-
-
- - -
- by?: - null - | - string -
- -
-
- - -
- for: - string -
- -
-
- - -
- host?: - null - | - string -
- -
-
- - -
- proto?: - null - | - string -
- -
-
- - -
- secret?: - null - | - string -
- -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/interfaces/IIfModifiedSince.html b/packages/binden/docs/interfaces/IIfModifiedSince.html deleted file mode 100644 index 34797b90..00000000 --- a/packages/binden/docs/interfaces/IIfModifiedSince.html +++ /dev/null @@ -1,345 +0,0 @@ - - - - - - IIfModifiedSince | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Interface IIfModifiedSince

-
-
- interface - IIfModifiedSince - {
    date: - Date;
} -
-
-

Implemented by

- -
- -
-
-
- - - -
-
-

Properties

-
- - date -
-
-
-
-
-
-
- -

- - - - Properties -

-
-
-
- - -
- date: - Date -
- -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/interfaces/IMiddlewareParams.html b/packages/binden/docs/interfaces/IMiddlewareParams.html deleted file mode 100644 index 8cb10d2e..00000000 --- a/packages/binden/docs/interfaces/IMiddlewareParams.html +++ /dev/null @@ -1,385 +0,0 @@ - - - - - - IMiddlewareParams | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Interface IMiddlewareParams

-
-
- interface - IMiddlewareParams - {
    disabled?: - boolean;
    ignore_errors?: - boolean;
} -
- -
-
-
- - - -
-
-

Properties

- -
-
-
-
-
-
- -

- - - - Properties -

-
-
-
- - -
- disabled?: - boolean -
- -
-
- - -
- ignore_errors?: - boolean -
- -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/interfaces/IRange.html b/packages/binden/docs/interfaces/IRange.html deleted file mode 100644 index 09874526..00000000 --- a/packages/binden/docs/interfaces/IRange.html +++ /dev/null @@ -1,415 +0,0 @@ - - - - - - IRange | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Interface IRange

-
-
- interface - IRange - {
    end?: - null - | - number;
    start?: - null - | - number;
} -
-
-

- Hierarchy (View Summary) -

- -
-
-

Implemented by

- -
- -
-
-
- - - -
-
-

Properties

- -
-
-
-
-
-
- -

- - - - Properties -

-
-
-
- - -
- end?: - null - | - number -
- -
-
- - -
- start?: - null - | - number -
- -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/interfaces/IRouterOptions.html b/packages/binden/docs/interfaces/IRouterOptions.html deleted file mode 100644 index 5e5deb6c..00000000 --- a/packages/binden/docs/interfaces/IRouterOptions.html +++ /dev/null @@ -1,336 +0,0 @@ - - - - - - IRouterOptions | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Interface IRouterOptions

-
-
- interface - IRouterOptions - {
    guarded?: - boolean;
} -
- -
-
-
- - - -
-
-

Properties

- -
-
-
-
-
-
- -

- - - - Properties -

-
-
-
- - -
- guarded?: - boolean -
- -
-
-
-
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/modules.html b/packages/binden/docs/modules.html deleted file mode 100644 index aac2e2b5..00000000 --- a/packages/binden/docs/modules.html +++ /dev/null @@ -1,1190 +0,0 @@ - - - - - - binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

binden

-
-
- -

- - - - Classes -

-
-
-
- - AcceptEncoding - -
-
-
- - Authorization - -
-
-
- - Binden - -
-
-
- - BindenError - -
-
-
- - BindenRequest - -
-
-
- - BindenResponse - -
-
-
- - ContentEncoding - -
-
-
- - ContentRange - -
-
-
- - ContentType - -
-
-
- - Context - -
-
-
- - Cookie - -
-
-
- - Forwarded - -
-
-
- - IfModifiedSince - -
-
-
- - Middleware - -
-
-
- - Range - -
-
-
- - Router - -
-
-
-
-
- -

- - - - Interfaces -

-
-
-
- - IAcceptEncoding - -
-
-
- - IAuthorization - -
-
-
- - IBaseContext - -
-
-
- - IBindenErrorOptions - -
-
-
- - IBindenOptions - -
-
-
- - IContentEncoding - -
-
-
- - IContentRange - -
-
-
- - IContentType - -
-
-
- - ICookie - -
-
-
- - IForwarded - -
-
-
- - IIfModifiedSince - -
-
-
- - IMiddlewareParams - -
-
-
- - IRange - -
-
-
- - IRouterOptions - -
-
-
-
-
- -

- - - - Type Aliases -

-
-
-
- - IAcceptEncodings - -
-
-
- - IAuthenticationType - -
-
-
- - IEncodings - -
-
-
- - IFunctionMiddleware - -
-
-
- - IHeaders - -
-
-
- - IHeadersValue - -
-
-
- - IMiddleware - -
-
-
- - IProtocol - -
-
-
- - IStack - -
-
-
- - IStackItem - -
-
-
- - IUnit - -
-
-
-
-
- -

- - - - Variables -

-
-
-
- - ct_form - -
-
-
- - ct_html - -
-
-
- - ct_json - -
-
-
- - ct_text - -
-
-
- - DefaultErrorCode - -
-
-
-
-
- -
-
-
- - diff --git a/packages/binden/docs/types/IAcceptEncodings.html b/packages/binden/docs/types/IAcceptEncodings.html deleted file mode 100644 index 1d938be3..00000000 --- a/packages/binden/docs/types/IAcceptEncodings.html +++ /dev/null @@ -1,246 +0,0 @@ - - - - - - IAcceptEncodings | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Type Alias IAcceptEncodings

-
-
- IAcceptEncodings:
    | - "*"
    | - "br"
    | - "compress"
    | - "deflate"
    | - "gzip"
    | - "identity"
    | - "x-gzip" -
- -
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/types/IAuthenticationType.html b/packages/binden/docs/types/IAuthenticationType.html deleted file mode 100644 index 9ce54b5e..00000000 --- a/packages/binden/docs/types/IAuthenticationType.html +++ /dev/null @@ -1,248 +0,0 @@ - - - - - - IAuthenticationType | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Type Alias IAuthenticationType

-
-
- IAuthenticationType:
    | - "AWS4-HMAC-SHA256"
    | - "Basic"
    | - "Bearer"
    | - "Digest"
    | - "HOBA"
    | - "Mutual"
    | - "OAuth"
    | - "SCRAM-SHA-1"
    | - "SCRAM-SHA-256"
    | - "vapid" -
- -
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/types/IEncodings.html b/packages/binden/docs/types/IEncodings.html deleted file mode 100644 index 048c0654..00000000 --- a/packages/binden/docs/types/IEncodings.html +++ /dev/null @@ -1,235 +0,0 @@ - - - - - - IEncodings | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Type Alias IEncodings

-
-
- IEncodings: - "br" - | - "compress" - | - "deflate" - | - "gzip" - | - "x-gzip" -
- -
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/types/IFunctionMiddleware.html b/packages/binden/docs/types/IFunctionMiddleware.html deleted file mode 100644 index c08d20e1..00000000 --- a/packages/binden/docs/types/IFunctionMiddleware.html +++ /dev/null @@ -1,280 +0,0 @@ - - - - - - IFunctionMiddleware | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Type Alias IFunctionMiddleware

-
-
- IFunctionMiddleware: - (context: - Context) - => - IMiddlewareReturnType -
-
-

Type declaration

-
    -
  • -
      -
    • - (context: - Context): - IMiddlewareReturnType -
    • -
    • -
      -

      Parameters

      - -
      -

      - Returns - IMiddlewareReturnType -

      -
    • -
    -
  • -
-
- -
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/types/IHeaders.html b/packages/binden/docs/types/IHeaders.html deleted file mode 100644 index cdc60c7d..00000000 --- a/packages/binden/docs/types/IHeaders.html +++ /dev/null @@ -1,235 +0,0 @@ - - - - - - IHeaders | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Type Alias IHeaders

-
-
- IHeaders: - Record<string, - IHeadersValue> -
- -
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/types/IHeadersValue.html b/packages/binden/docs/types/IHeadersValue.html deleted file mode 100644 index 162ebe6e..00000000 --- a/packages/binden/docs/types/IHeadersValue.html +++ /dev/null @@ -1,233 +0,0 @@ - - - - - - IHeadersValue | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Type Alias IHeadersValue

-
-
- IHeadersValue: - number - | - string - | - readonly - string[] -
- -
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/types/IMiddleware.html b/packages/binden/docs/types/IMiddleware.html deleted file mode 100644 index 44935b83..00000000 --- a/packages/binden/docs/types/IMiddleware.html +++ /dev/null @@ -1,237 +0,0 @@ - - - - - - IMiddleware | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Type Alias IMiddleware

-
-
- IMiddleware: - IFunctionMiddleware - | - Middleware -
- -
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/types/IProtocol.html b/packages/binden/docs/types/IProtocol.html deleted file mode 100644 index 2d36047f..00000000 --- a/packages/binden/docs/types/IProtocol.html +++ /dev/null @@ -1,229 +0,0 @@ - - - - - - IProtocol | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Type Alias IProtocol

-
-
- IProtocol: - "http:" - | - "https:" -
- -
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/types/IStack.html b/packages/binden/docs/types/IStack.html deleted file mode 100644 index b404f3a5..00000000 --- a/packages/binden/docs/types/IStack.html +++ /dev/null @@ -1,239 +0,0 @@ - - - - - - IStack | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Type Alias IStack

-
-
- IStack: - [IStackItem[], - RegExp - | - string - | - null] -
- -
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/types/IStackItem.html b/packages/binden/docs/types/IStackItem.html deleted file mode 100644 index f1ea4db8..00000000 --- a/packages/binden/docs/types/IStackItem.html +++ /dev/null @@ -1,237 +0,0 @@ - - - - - - IStackItem | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Type Alias IStackItem

-
-
- IStackItem: - IMiddleware - | - Router -
- -
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/types/IUnit.html b/packages/binden/docs/types/IUnit.html deleted file mode 100644 index 3b7386b6..00000000 --- a/packages/binden/docs/types/IUnit.html +++ /dev/null @@ -1,227 +0,0 @@ - - - - - - IUnit | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Type Alias IUnit

-
-
- IUnit: - "bytes" -
- -
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/variables/DefaultErrorCode.html b/packages/binden/docs/variables/DefaultErrorCode.html deleted file mode 100644 index 2112f5e1..00000000 --- a/packages/binden/docs/variables/DefaultErrorCode.html +++ /dev/null @@ -1,227 +0,0 @@ - - - - - - DefaultErrorCode | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Variable DefaultErrorCodeConst

-
-
- DefaultErrorCode: - 500 -
- -
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/variables/ct_form.html b/packages/binden/docs/variables/ct_form.html deleted file mode 100644 index 4936259e..00000000 --- a/packages/binden/docs/variables/ct_form.html +++ /dev/null @@ -1,229 +0,0 @@ - - - - - - ct_form | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Variable ct_formConst

-
-
- ct_form: - "application/x-www-form-urlencoded" -
- -
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/variables/ct_html.html b/packages/binden/docs/variables/ct_html.html deleted file mode 100644 index 3e8265cf..00000000 --- a/packages/binden/docs/variables/ct_html.html +++ /dev/null @@ -1,227 +0,0 @@ - - - - - - ct_html | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Variable ct_htmlConst

-
-
- ct_html: - "text/html" -
- -
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/variables/ct_json.html b/packages/binden/docs/variables/ct_json.html deleted file mode 100644 index 12c30098..00000000 --- a/packages/binden/docs/variables/ct_json.html +++ /dev/null @@ -1,227 +0,0 @@ - - - - - - ct_json | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Variable ct_jsonConst

-
-
- ct_json: - "application/json" -
- -
-
- - -
-
-
-
- - diff --git a/packages/binden/docs/variables/ct_text.html b/packages/binden/docs/variables/ct_text.html deleted file mode 100644 index 11fab463..00000000 --- a/packages/binden/docs/variables/ct_text.html +++ /dev/null @@ -1,227 +0,0 @@ - - - - - - ct_text | binden - - - - - - - - - - - - -
-
- -
- - -
-
-
-
-
-
- -

Variable ct_textConst

-
-
- ct_text: - "plain/text" -
- -
-
- - -
-
-
-
- - diff --git a/packages/binden/eslint.config.js b/packages/binden/eslint.config.js deleted file mode 100644 index 27d6d4d6..00000000 --- a/packages/binden/eslint.config.js +++ /dev/null @@ -1,5 +0,0 @@ -import config from "eslint-config-binden-ts"; -export default [ - ...config, - { languageOptions: { parserOptions: { project: "tsconfig.json" } } }, -]; diff --git a/packages/binden/package.json b/packages/binden/package.json index 9ad9fe36..816e21ea 100644 --- a/packages/binden/package.json +++ b/packages/binden/package.json @@ -1,40 +1,28 @@ { "name": "binden", - "version": "2.0.4", + "version": "3.0.0", "description": "A simple HTTP server framework", "main": "dist/index.js", "type": "module", "types": "dist/index.d.ts", + "files": [ + "dist", + "!dist/test" + ], "engines": { - "node": ">=20.11.0", - "npm": ">=10.2.4" + "node": ">=24.14.1", + "npm": ">=11.11.0" }, "scripts": { "build": "tsc", - "build:remove": "rm -rf dist", - "ci:all": "npm ci && npm run prettier && npm run eslint && npm run commitlint && npm test && npm publish --dry-run && npm run docs:build && npm run coverage", - "commitlint": "commitlint --verbose --from=$( git rev-list --max-parents=0 $( git rev-parse --abbrev-ref HEAD ) )", - "coverage": "c8 npm test", - "docs:build": "typedoc", - "eslint": "eslint **/*.ts", - "eslint:fix": "npm run eslint -- --fix", - "install:clean": "npm install", - "postbuild": "prettier -c -w dist", - "postcoverage": "c8 report --reporter=lcov", - "postdocs:build": "prettier -c -w docs", - "postpublish": "npm run build:remove", - "prebuild": "npm run build:remove", - "preinstall:clean": "rm -rf node_modules package-lock.json", - "prepare": "husky", - "prepublishOnly": "npm run build", - "prettier": "prettier -c .", - "prettier:write": "prettier -c -w .", - "preversion": "npm run docs:build", - "test": "mocha" + "prebuild": "rm -rf dist", + "prepack": "npm run build", + "test": "node --import tsx --import ./test/setup.ts --test --test-concurrency=1 --test-timeout=60000 \"test/**/*.test.ts\"" }, "repository": { "type": "git", - "url": "git+https://github.com/binden-js/binden.git" + "url": "git+https://github.com/binden-js/binden.git", + "directory": "packages/binden" }, "keywords": [ "HTTP", @@ -44,34 +32,21 @@ ], "author": "Sergey Bakulin ", "license": "MIT", - "directories": { - "doc": "docs", - "test": "test" - }, "bugs": { "url": "https://github.com/binden-js/binden/issues" }, - "homepage": "https://github.com/binden-js/binden#readme", + "homepage": "https://github.com/binden-js/binden/tree/main/packages/binden#readme", "dependencies": { - "@binden/logger": "^2.0.3" + "@binden/logger": "^3.0.0" }, "devDependencies": { - "@binden/commitlint-config": "^2.1.0", - "@binden/semantic-release-config": "^3.0.4", - "@binden/tsconfig": "^2.0.0", - "@types/mocha": "^10.0.7", "@types/sinon": "^17.0.3", - "c8": "^11.0.0", - "eslint-config-binden-ts": "^4.0.2", "fast-json-stringify": "^7.0.1", - "husky": "^9.1.7", - "lint-staged": "^17.3.0", - "mocha": "^11.0.1", "node-fetch": "^3.3.2", - "prettier": "^3.8.4", - "sinon": "^19.0.2", - "ts-node": "^10.9.2", - "typedoc": "^0.28.19" + "sinon": "^19.0.2" + }, + "publishConfig": { + "access": "public" }, "funding": { "type": "Coinbase Commerce", diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index c1556a29..1899ffc2 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -3,7 +3,7 @@ import https from "node:https"; import { isDeepStrictEqual } from "node:util"; import { Context } from "./context.js"; import { BindenError } from "./error.js"; -import { Middleware, IMiddleware } from "./middleware.js"; +import { Middleware, type IMiddleware } from "./middleware.js"; import { BindenRequest } from "./request.js"; import { BindenResponse, ct_text, ct_json } from "./response.js"; import { Router } from "./router.js"; diff --git a/packages/binden/src/context.ts b/packages/binden/src/context.ts index e79538bc..2d118913 100644 --- a/packages/binden/src/context.ts +++ b/packages/binden/src/context.ts @@ -1,5 +1,5 @@ import { Logger } from "@binden/logger"; -import { BindenError, IBindenErrorOptions } from "./error.js"; +import { BindenError, type IBindenErrorOptions } from "./error.js"; import logger from "./logger.js"; import type { Readable } from "node:stream"; diff --git a/packages/binden/src/headers/accept-encoding.ts b/packages/binden/src/headers/accept-encoding.ts index 500eab06..58ce411f 100644 --- a/packages/binden/src/headers/accept-encoding.ts +++ b/packages/binden/src/headers/accept-encoding.ts @@ -1,11 +1,5 @@ export type IAcceptEncodings = - | "*" - | "br" - | "compress" - | "deflate" - | "gzip" - | "identity" - | "x-gzip"; + "*" | "br" | "compress" | "deflate" | "gzip" | "identity" | "x-gzip"; export interface IAcceptEncoding { encoding: IAcceptEncodings; diff --git a/packages/binden/src/headers/content-range.ts b/packages/binden/src/headers/content-range.ts index 0cdfd3e3..58155f32 100644 --- a/packages/binden/src/headers/content-range.ts +++ b/packages/binden/src/headers/content-range.ts @@ -1,4 +1,4 @@ -import { Range, IRange } from "./range.js"; +import { Range, type IRange } from "./range.js"; export interface IContentRange extends IRange { readonly size: number | "*"; diff --git a/packages/binden/src/request.ts b/packages/binden/src/request.ts index 55072873..b6e1fc98 100644 --- a/packages/binden/src/request.ts +++ b/packages/binden/src/request.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { IncomingMessage } from "node:http"; -import { parse, ParsedUrlQuery } from "node:querystring"; +import { parse, type ParsedUrlQuery } from "node:querystring"; import { TLSSocket } from "node:tls"; import AcceptEncoding from "./headers/accept-encoding.js"; diff --git a/packages/binden/src/router.ts b/packages/binden/src/router.ts index fbcaf624..361ec209 100644 --- a/packages/binden/src/router.ts +++ b/packages/binden/src/router.ts @@ -1,5 +1,5 @@ import { METHODS } from "node:http"; -import { Middleware, IMiddleware } from "./middleware.js"; +import { Middleware, type IMiddleware } from "./middleware.js"; export interface IRouterOptions { guarded?: boolean; @@ -49,9 +49,9 @@ export class Router { } for (const middleware of _middlewares) { - if ( - !(middleware instanceof Middleware || typeof middleware === "function") - ) { + if (!( + middleware instanceof Middleware || typeof middleware === "function" + )) { throw new TypeError("Middleware is not supported"); } } diff --git a/packages/binden/tsconfig.json b/packages/binden/tsconfig.json index c6af8f5d..9536a0f4 100644 --- a/packages/binden/tsconfig.json +++ b/packages/binden/tsconfig.json @@ -1 +1,3 @@ -{ "extends": "@binden/tsconfig", "compilerOptions": { "outDir": "dist" } } +{ + "extends": "../../tsconfig.base.json" +} diff --git a/packages/binden/typedoc.json b/packages/binden/typedoc.json deleted file mode 100644 index 7cdd55f0..00000000 --- a/packages/binden/typedoc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "out": "docs", - "entryPoints": ["./index.ts"], - "name": "binden", - "exclude": ["test/**/*"], - "hideGenerator": true, - "gitRevision": "main" -} diff --git a/packages/body/index.ts b/packages/body/index.ts index d4c51b9a..9bc5b8e5 100644 --- a/packages/body/index.ts +++ b/packages/body/index.ts @@ -3,7 +3,7 @@ import { createInflate, createGunzip, createBrotliDecompress } from "node:zlib"; import { BindenError, Middleware, - IMiddlewareParams, + type IMiddlewareParams, Context, ct_text, ct_json, diff --git a/packages/body/package.json b/packages/body/package.json index 2ccb61c8..aa6ecf3b 100644 --- a/packages/body/package.json +++ b/packages/body/package.json @@ -1,40 +1,28 @@ { "name": "@binden/body", - "version": "2.0.1", + "version": "3.0.0", "description": "Binden body parser middleware", "main": "dist/index.js", "type": "module", "types": "dist/index.d.ts", + "files": [ + "dist", + "!dist/test" + ], "engines": { - "node": ">=20.11.0", - "npm": ">=10.2.4" + "node": ">=24.14.1", + "npm": ">=11.11.0" }, "scripts": { "build": "tsc", - "build:remove": "rm -rf dist", - "ci:all": "npm ci && npm run prettier && npm run eslint && npm run commitlint && npm test && npm publish --dry-run && npm run docs:build && npm run coverage", - "commitlint": "commitlint --verbose --from=$( git rev-list --max-parents=0 $( git rev-parse --abbrev-ref HEAD ) )", - "coverage": "c8 npm test", - "docs:build": "typedoc", - "eslint": "eslint --ext .ts ./", - "eslint:fix": "npm run eslint -- --fix", - "install:clean": "npm install", - "postbuild": "prettier -c -w dist", - "postcoverage": "c8 report", - "postdocs:build": "prettier -c -w docs", - "postpublish": "npm run build:remove", - "prebuild": "npm run build:remove", - "preinstall:clean": "rm -rf node_modules package-lock.json", - "prepare": "husky", - "prepublishOnly": "npm run build", - "prettier": "prettier -c .", - "prettier:write": "prettier -c -w .", - "preversion": "npm run docs:build", - "test": "mocha" + "prebuild": "rm -rf dist", + "prepack": "npm run build", + "test": "node --import tsx --import ./test/setup.ts --test --test-concurrency=1 --test-timeout=60000 \"test/**/*.test.ts\"" }, "repository": { "type": "git", - "url": "git+https://github.com/binden-js/body.git" + "url": "git+https://github.com/binden-js/binden.git", + "directory": "packages/body" }, "keywords": [ "HTTP", @@ -43,31 +31,15 @@ ], "author": "Sergey Bakulin ", "license": "AGPL-3.0-only", - "directories": { - "doc": "docs", - "test": "test" - }, "bugs": { - "url": "https://github.com/binden-js/body/issues" + "url": "https://github.com/binden-js/binden/issues" }, - "homepage": "https://github.com/binden-js/body#readme", + "homepage": "https://github.com/binden-js/binden/tree/main/packages/body#readme", "peerDependencies": { - "binden": "^2.0.2" + "binden": "^3.0.0" }, "devDependencies": { - "@binden/commitlint-config": "^3.0.0", - "@binden/semantic-release-config": "^3.0.2", - "@binden/tsconfig": "^2.0.0", - "@types/mocha": "^10.0.10", "ajv": "^8.20.0", - "c8": "^11.0.0", - "eslint-config-binden-ts": "^3.0.0", - "husky": "^9.1.7", - "lint-staged": "^15.5.1", - "mocha": "^11.7.6", - "prettier": "^3.5.3", - "ts-node": "^10.9.2", - "typedoc": "^0.28.14", "undici": "^7.11.0" }, "publishConfig": { diff --git a/packages/body/tsconfig.json b/packages/body/tsconfig.json new file mode 100644 index 00000000..9536a0f4 --- /dev/null +++ b/packages/body/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.base.json" +} diff --git a/packages/compression/package.json b/packages/compression/package.json index aca1292c..89862d08 100644 --- a/packages/compression/package.json +++ b/packages/compression/package.json @@ -5,36 +5,24 @@ "main": "dist/index.js", "type": "module", "types": "dist/index.d.ts", + "files": [ + "dist", + "!dist/test" + ], "engines": { "node": ">=24.14.1", "npm": ">=11.11.0" }, "scripts": { "build": "tsc", - "build:remove": "rm -rf dist", - "ci:all": "npm ci && npm run prettier && npm run eslint && npm run commitlint && npm test && npm publish --dry-run && npm run docs:build && npm run coverage", - "commitlint": "commitlint --verbose --from=$( git rev-list --max-parents=0 $( git rev-parse --abbrev-ref HEAD ) )", - "coverage": "c8 npm test", - "docs:build": "typedoc", - "eslint": "eslint --ext .ts", - "eslint:fix": "npm run eslint -- --fix", - "install:clean": "npm install", - "postbuild": "prettier -c -w dist", - "postcoverage": "c8 report --reporter=lcov", - "postdocs:build": "prettier -c -w docs", - "postpublish": "npm run build:remove", - "prebuild": "npm run build:remove", - "preinstall:clean": "rm -rf node_modules package-lock.json", - "prepare": "husky", - "prepublishOnly": "npm run build", - "prettier": "prettier -c .", - "prettier:write": "prettier -c -w .", - "preversion": "npm run docs:build", - "test": "node --import tsx --test test/*.test.ts" + "prebuild": "rm -rf dist", + "prepack": "npm run build", + "test": "node --import tsx --import ./test/setup.ts --test --test-concurrency=1 --test-timeout=60000 \"test/**/*.test.ts\"" }, "repository": { "type": "git", - "url": "git+https://github.com/binden-js/compression.git" + "url": "git+https://github.com/binden-js/binden.git", + "directory": "packages/compression" }, "keywords": [ "compress", @@ -46,28 +34,14 @@ ], "author": "Sergey Bakulin ", "license": "AGPL-3.0-only", - "directories": { - "doc": "docs", - "test": "test" - }, "bugs": { - "url": "https://github.com/binden-js/compression/issues" + "url": "https://github.com/binden-js/binden/issues" }, - "homepage": "https://github.com/binden-js/compression#readme", + "homepage": "https://github.com/binden-js/binden/tree/main/packages/compression#readme", "peerDependencies": { - "binden": "^2.0.2" + "binden": "^3.0.0" }, "devDependencies": { - "@binden/commitlint-config": "^3.0.0", - "@binden/eslint-config-ts": "^6.0.1", - "@binden/semantic-release-config": "^3.0.4", - "@binden/tsconfig": "^4.0.1", - "c8": "^11.0.0", - "husky": "^9.1.7", - "lint-staged": "^17.3.0", - "prettier": "^3.8.3", - "tsx": "^4.22.5", - "typedoc": "^0.28.19", "undici": "^8.3.0" }, "publishConfig": { diff --git a/packages/compression/tsconfig.json b/packages/compression/tsconfig.json new file mode 100644 index 00000000..9536a0f4 --- /dev/null +++ b/packages/compression/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.base.json" +} diff --git a/packages/logger/index.ts b/packages/logger/index.ts index 66d8f814..746f977a 100644 --- a/packages/logger/index.ts +++ b/packages/logger/index.ts @@ -1,10 +1,10 @@ import pino, { stdSerializers, - Bindings, - Logger as PinoLogger, - LoggerOptions as PinoLoggerOptions, - DestinationStream, - LevelWithSilent, + type Bindings, + type Logger as PinoLogger, + type LoggerOptions as PinoLoggerOptions, + type DestinationStream, + type LevelWithSilent, } from "pino"; export interface LoggerOptions extends PinoLoggerOptions { @@ -24,7 +24,7 @@ export class Logger { public constructor({ logger, destination, ...options }: LoggerOptions = {}) { this.#logger = destination ? pino({ level: Logger.getLevel(), serializers, ...options }, destination) - : logger ?? pino({ level: Logger.getLevel(), serializers, ...options }); + : (logger ?? pino({ level: Logger.getLevel(), serializers, ...options })); } public child(params: Bindings = {}): Logger { diff --git a/packages/logger/package.json b/packages/logger/package.json index 5b14fd65..1dbbf09f 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -1,63 +1,41 @@ { "name": "@binden/logger", - "version": "2.0.3", + "version": "3.0.0", "description": "A simple logger (based on pino)", "main": "dist/index.js", "type": "module", "types": "dist/index.d.ts", + "files": [ + "dist", + "!dist/test" + ], "engines": { - "node": ">=20.11.0", - "npm": ">=10.2.4" + "node": ">=24.14.1", + "npm": ">=11.11.0" }, "scripts": { "build": "tsc", - "build:remove": "rm -rf ./dist", - "ci:all": "npm ci && npm run prettier && npm run eslint && npm run commitlint && npm run docs:build && npm publish --dry-run", - "commitlint": "commitlint --verbose --from=$( git rev-list --max-parents=0 $( git rev-parse --abbrev-ref HEAD ) )", - "docs:build": "typedoc", - "eslint": "eslint **/*.ts", - "install:clean": "npm install", - "postbuild": "prettier -c -w ./dist", - "postdocs:build": "prettier -c -w ./docs", - "postpublish": "npm run build:remove", - "prebuild": "npm run build:remove", - "preinstall:clean": "rm -rf node_modules package-lock.json", - "prepare": "husky", - "prepublishOnly": "npm run build", - "prettier": "prettier -c .", - "prettier:write": "prettier -c -w .", - "preversion": "npm run docs:build" + "prebuild": "rm -rf dist", + "prepack": "npm run build", + "test": "node --import tsx --test --test-concurrency=1 --test-timeout=60000 \"test/**/*.test.ts\"" }, "repository": { "type": "git", - "url": "git+https://github.com/binden-js/logger.git" + "url": "git+https://github.com/binden-js/binden.git", + "directory": "packages/logger" }, "keywords": [ "logger" ], "author": "Sergey Bakulin ", "license": "MIT", - "directories": { - "doc": "docs" - }, "bugs": { - "url": "https://github.com/binden-js/logger/issues" + "url": "https://github.com/binden-js/binden/issues" }, - "homepage": "https://github.com/binden-js/logger#readme", + "homepage": "https://github.com/binden-js/binden/tree/main/packages/logger#readme", "dependencies": { "pino": "^10.3.1" }, - "devDependencies": { - "@binden/commitlint-config": "^2.1.0", - "@binden/semantic-release-config": "^3.0.0", - "@binden/tsconfig": "^2.0.0", - "eslint-config-binden-ts": "^4.0.2", - "husky": "^9.1.7", - "lint-staged": "^15.2.10", - "prettier": "^3.3.2", - "ts-node": "^10.9.2", - "typedoc": "^0.26.8" - }, "publishConfig": { "access": "public" }, diff --git a/packages/logger/tsconfig.json b/packages/logger/tsconfig.json new file mode 100644 index 00000000..9536a0f4 --- /dev/null +++ b/packages/logger/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.base.json" +} diff --git a/packages/multipart/index.ts b/packages/multipart/index.ts index 4b0c27fe..fda5b166 100644 --- a/packages/multipart/index.ts +++ b/packages/multipart/index.ts @@ -68,12 +68,13 @@ export class Multipart extends Middleware { fd.append(name, value); }) .on("file", (name, stream, { mimeType: type, filename }) => { - const chunks: Buffer[] = []; + const chunks: Buffer[] = []; stream .once("close", () => { - fd.append(name, new File(chunks, filename, { type })); + // @types/busboy 1.5.5 types `filename` as optional; the value is passed through unchanged + fd.append(name, new File(chunks, filename as string, { type })); }) - .on("data", (chunk: Buffer) => { + .on("data", (chunk: Buffer) => { chunks.push(chunk); }) .once("limit", () => { diff --git a/packages/multipart/package.json b/packages/multipart/package.json index 2da49b1e..4dff8b34 100644 --- a/packages/multipart/package.json +++ b/packages/multipart/package.json @@ -1,44 +1,28 @@ { "name": "@binden/multipart", - "version": "2.0.3", + "version": "3.0.0", "description": "Binden middleware for parsing incoming FormData", - "main": "./dist/index.js", + "main": "dist/index.js", "type": "module", - "types": "./dist/index.d.ts", - "directories": { - "doc": "docs", - "test": "test" - }, + "types": "dist/index.d.ts", + "files": [ + "dist", + "!dist/test" + ], "engines": { - "node": ">=22.17.0", - "npm": ">=10.9.2" + "node": ">=24.14.1", + "npm": ">=11.11.0" }, "scripts": { "build": "tsc", - "build:remove": "rm -rf dist", - "ci:all": "npm ci && npm run prettier && npm run eslint && npm run commitlint && npm test && npm publish --dry-run && npm run docs:build && npm run coverage", - "commitlint": "commitlint --verbose --from=$( git rev-list --max-parents=0 $( git rev-parse --abbrev-ref HEAD ) )", - "coverage": "c8 npm test", - "docs:build": "typedoc", - "eslint": "eslint **/*.ts", - "eslint:fix": "npm run eslint -- --fix", - "install:clean": "npm install", - "postbuild": "prettier -c -w dist", - "postcoverage": "c8 report", - "postdocs:build": "prettier -c -w docs", - "postpublish": "npm run build:remove", - "prebuild": "npm run build:remove", - "preinstall:clean": "rm -rf node_modules package-lock.json", - "prepare": "husky", - "prepublishOnly": "npm run build", - "prettier": "prettier -c .", - "prettier:write": "prettier -c -w .", - "preversion": "npm run docs:build", - "test": "node --loader ts-node/esm --test test/*.test.ts" + "prebuild": "rm -rf dist", + "prepack": "npm run build", + "test": "node --import tsx --import ./test/setup.ts --test --test-concurrency=1 --test-timeout=60000 \"test/**/*.test.ts\"" }, "repository": { "type": "git", - "url": "git+https://github.com/binden-js/multipart.git" + "url": "git+https://github.com/binden-js/binden.git", + "directory": "packages/multipart" }, "keywords": [ "multipart", @@ -48,29 +32,20 @@ ], "author": "Sergey Bakulin ", "license": "AGPL-3.0-only", + "bugs": { + "url": "https://github.com/binden-js/binden/issues" + }, + "homepage": "https://github.com/binden-js/binden/tree/main/packages/multipart#readme", "dependencies": { "busboy": "^1.6.0" }, "peerDependencies": { "@types/busboy": "^1.5.4", - "binden": "^2.0.4" + "binden": "^3.0.0" }, "devDependencies": { - "@binden/commitlint-config": "^2.1.0", - "@binden/semantic-release-config": "^3.0.2", - "@binden/tsconfig": "^3.0.0", - "c8": "^10.1.3", - "eslint-config-binden-ts": "^5.0.2", - "husky": "^9.1.7", - "lint-staged": "^16.2.7", - "prettier": "^3.6.2", - "ts-node": "^10.9.2", - "typedoc": "^0.28.6" - }, - "bugs": { - "url": "https://github.com/binden-js/multipart/issues" + "undici": "^8.3.0" }, - "homepage": "https://github.com/binden-js/multipart#readme", "publishConfig": { "access": "public" }, diff --git a/packages/multipart/tsconfig.json b/packages/multipart/tsconfig.json new file mode 100644 index 00000000..9536a0f4 --- /dev/null +++ b/packages/multipart/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.base.json" +} diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 00000000..796d1009 --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,163 @@ +#!/usr/bin/env node +// Lockstep release helper for the npm workspaces of this repository. +// +// check all workspaces share one version and pin each other to `^` +// verify none of the workspaces is published at yet +// version set everywhere and refresh package-lock.json +// publish publish every workspace in the `workspaces` order +// (`--skip-existing` skips already published ones, for manual recovery) +// +// Provenance is enabled by the CI release job (NPM_CONFIG_PROVENANCE), so a manual +// `publish --skip-existing` works outside of CI too. +// Everything is logged to stderr: @semantic-release/exec parses the stdout of `publishCmd`. +import { readFileSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; + +const root = join(import.meta.dirname, ".."); +const dependencyFields = [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", +]; + +function readJSON(file) { + return JSON.parse(readFileSync(file, "utf8")); +} + +function log(message) { + process.stderr.write(`[release] ${message}\n`); +} + +function npm(args, options = {}) { + return execFileSync("npm", args, { cwd: root, encoding: "utf8", ...options }); +} + +function workspaces() { + return readJSON(join(root, "package.json")).workspaces.map((dir) => { + const file = join(root, dir, "package.json"); + return { dir, file, manifest: readJSON(file) }; + }); +} + +function errorCode(stdout) { + try { + return JSON.parse(stdout).error?.code ?? null; + } catch { + return null; + } +} + +// Only a confirmed "not found" (E404) means "not published": registry, network or +// authentication errors must stop the release before anything is committed or tagged. +function isPublished(name, version) { + try { + const stdout = npm(["view", `${name}@${version}`, "version", "--json"], { + stdio: ["ignore", "pipe", "pipe"], + }); + return stdout.trim() !== ""; + } catch (error) { + if (errorCode(error.stdout) === "E404") { + return false; + } + throw error; + } +} + +function check() { + const packages = workspaces(); + const names = new Set(packages.map(({ manifest }) => manifest.name)); + const [{ manifest: first }] = packages; + const errors = []; + for (const { dir, manifest } of packages) { + if (manifest.version !== first.version) { + errors.push(`${dir}: version ${manifest.version} != ${first.version}`); + } + for (const field of dependencyFields) { + for (const [name, range] of Object.entries(manifest[field] ?? {})) { + if (names.has(name) && range !== `^${first.version}`) { + errors.push( + `${dir}: ${field}.${name} is ${range}, expected ^${first.version}`, + ); + } + } + } + } + if (errors.length > 0) { + throw new Error( + `Workspaces are not in lockstep:\n ${errors.join("\n ")}`, + ); + } + log(`${packages.length} workspaces in lockstep at ${first.version}`); +} + +function verify(version) { + const published = workspaces() + .map(({ manifest }) => manifest.name) + .filter((name) => isPublished(name, version)); + if (published.length > 0) { + throw new Error(`Already published at ${version}: ${published.join(", ")}`); + } + log(`no workspace is published at ${version} yet`); +} + +function setVersion(version) { + const packages = workspaces(); + const names = new Set(packages.map(({ manifest }) => manifest.name)); + for (const { file, manifest } of packages) { + manifest.version = version; + for (const field of dependencyFields) { + for (const name of Object.keys(manifest[field] ?? {})) { + if (names.has(name)) { + manifest[field][name] = `^${version}`; + } + } + } + writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`); + log(`${manifest.name} -> ${version}`); + } + npm(["install", "--package-lock-only", "--ignore-scripts"], { + stdio: ["ignore", process.stderr, "inherit"], + }); + check(); +} + +function publish(skipExisting) { + check(); + for (const { dir, manifest } of workspaces()) { + if (skipExisting && isPublished(manifest.name, manifest.version)) { + log(`skip ${manifest.name}@${manifest.version}: already published`); + } else { + log(`publish ${manifest.name}@${manifest.version}`); + npm(["publish", "--workspace", dir], { + stdio: ["ignore", process.stderr, "inherit"], + }); + } + } +} + +const [command, argument] = process.argv.slice(2); +const versionPattern = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/u; + +switch (command) { + case "check": + check(); + break; + case "verify": + case "version": + if (!versionPattern.test(argument ?? "")) { + throw new Error(`Invalid version: ${argument}`); + } + if (command === "verify") { + verify(argument); + } else { + setVersion(argument); + } + break; + case "publish": + publish(argument === "--skip-existing"); + break; + default: + throw new Error(`Unknown command: ${command}`); +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 00000000..0bb7f06b --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "esnext", + "experimentalDecorators": true, + "useDefineForClassFields": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "resolveJsonModule": true, + "declaration": true, + "newLine": "lf", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "useUnknownInCatchVariables": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "types": ["node"], + "outDir": "${configDir}/dist" + } +} diff --git a/typedoc.json b/typedoc.json new file mode 100644 index 00000000..f2913bc6 --- /dev/null +++ b/typedoc.json @@ -0,0 +1,11 @@ +{ + "out": "docs", + "name": "binden", + "entryPointStrategy": "packages", + "entryPoints": ["packages/*"], + "packageOptions": { + "entryPoints": ["index.ts"] + }, + "hideGenerator": true, + "gitRevision": "main" +} From c2cba249ff8562fcf231938f6f7910b30881d954 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 17:07:38 +0300 Subject: [PATCH 006/675] test: run the tests with node:test and tsx Replace mocha (TDD interface) and ts-node with the native `node:test` runner and tsx: `setup`/`teardown` become `beforeEach`/`afterEach`, type-only imports are marked as such (tsx transpiles files in isolation) and stale `eslint-disable` directives are updated. Every suite starts a new server on the same port before each test, and `node:test` does not yield to the I/O phase between tests, so a pooled keep-alive connection to the previous (closed) server could be reused (ECONNRESET in `binden`, a hanging test in `@binden/body` on Node.js 26.11). `test/setup.ts` disables keep-alive for the test clients (`node:http` agent and the undici global dispatcher, which `fetch` shares). Co-Authored-By: Claude Opus 5.5 --- packages/binden/test/binden.test.ts | 11 ++++++----- packages/binden/test/context.test.ts | 5 +++-- packages/binden/test/error.test.ts | 1 + packages/binden/test/headers/accept-encoding.test.ts | 1 + packages/binden/test/headers/authorization.test.ts | 1 + packages/binden/test/headers/content-encoding.test.ts | 1 + packages/binden/test/headers/content-range.test.ts | 1 + packages/binden/test/headers/content-type.test.ts | 1 + packages/binden/test/headers/cookie.test.ts | 1 + packages/binden/test/headers/forwarded.test.ts | 1 + .../binden/test/headers/if-modified-since.test.ts | 1 + packages/binden/test/headers/range.test.ts | 1 + packages/binden/test/middleware.test.ts | 1 + packages/binden/test/request.test.ts | 5 +++-- packages/binden/test/response.test.ts | 5 +++-- packages/binden/test/router.test.ts | 1 + packages/binden/test/setup.ts | 7 +++++++ packages/body/test/index.test.ts | 11 ++++++----- packages/body/test/setup.ts | 8 ++++++++ packages/compression/test/compression.test.ts | 2 +- packages/compression/test/setup.ts | 8 ++++++++ packages/multipart/test/multipart.test.ts | 2 +- packages/multipart/test/setup.ts | 10 ++++++++++ 23 files changed, 68 insertions(+), 18 deletions(-) create mode 100644 packages/binden/test/setup.ts create mode 100644 packages/body/test/setup.ts create mode 100644 packages/compression/test/setup.ts create mode 100644 packages/multipart/test/setup.ts diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index c351338d..75a35756 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -1,18 +1,19 @@ -/* eslint-disable init-declarations, class-methods-use-this, @typescript-eslint/no-throw-literal */ +/* eslint-disable init-declarations, class-methods-use-this, @typescript-eslint/only-throw-error */ import { deepStrictEqual, throws } from "node:assert"; import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import { Server } from "node:http"; import { Agent } from "node:https"; +import { afterEach, beforeEach, suite, test } from "node:test"; import fetch from "node-fetch"; import { DefaultErrorCode, Binden, BindenError, - IBindenErrorOptions, + type IBindenErrorOptions, Middleware, - IMiddlewareParams, + type IMiddlewareParams, Context, Router, ct_text, @@ -61,7 +62,7 @@ suite("Binden", () => { let app: Binden; let server: Server; - setup((done) => { + beforeEach((_t, done) => { app = new Binden(); server = app.createServer().listen(port, done); }); @@ -535,5 +536,5 @@ suite("Binden", () => { }); }); - teardown((done) => server.close(done)); + afterEach((_t, done) => server.close(done)); }); diff --git a/packages/binden/test/context.test.ts b/packages/binden/test/context.test.ts index a314622a..289326a2 100644 --- a/packages/binden/test/context.test.ts +++ b/packages/binden/test/context.test.ts @@ -1,6 +1,7 @@ /* eslint-disable init-declarations, @typescript-eslint/no-loop-func */ import { ok, deepStrictEqual } from "node:assert"; import { Server, createServer } from "node:http"; +import { afterEach, beforeEach, suite, test } from "node:test"; import fastJSON from "fast-json-stringify"; import fetch from "node-fetch"; import sinon from "sinon"; @@ -18,7 +19,7 @@ const url = `http://localhost:${port}`; suite("Context", () => { let server: Server; - setup((done) => { + beforeEach((_t, done) => { server = createServer({ IncomingMessage: BindenRequest, ServerResponse: BindenResponse, @@ -238,5 +239,5 @@ suite("Context", () => { await serverPromise; }); - teardown((done) => server.close(done)); + afterEach((_t, done) => server.close(done)); }); diff --git a/packages/binden/test/error.test.ts b/packages/binden/test/error.test.ts index 87477b08..1d51b572 100644 --- a/packages/binden/test/error.test.ts +++ b/packages/binden/test/error.test.ts @@ -1,5 +1,6 @@ import { deepStrictEqual, ok, throws } from "node:assert"; import { STATUS_CODES } from "node:http"; +import { suite, test } from "node:test"; import { BindenError } from "../index.js"; diff --git a/packages/binden/test/headers/accept-encoding.test.ts b/packages/binden/test/headers/accept-encoding.test.ts index 21d05cad..fdf7835d 100644 --- a/packages/binden/test/headers/accept-encoding.test.ts +++ b/packages/binden/test/headers/accept-encoding.test.ts @@ -1,4 +1,5 @@ import { deepStrictEqual } from "node:assert"; +import { suite, test } from "node:test"; import { AcceptEncoding } from "../../index.js"; diff --git a/packages/binden/test/headers/authorization.test.ts b/packages/binden/test/headers/authorization.test.ts index 7255ca4c..0c27f8f4 100644 --- a/packages/binden/test/headers/authorization.test.ts +++ b/packages/binden/test/headers/authorization.test.ts @@ -1,4 +1,5 @@ import { deepStrictEqual, ok } from "node:assert"; +import { suite, test } from "node:test"; import { Authorization } from "../../index.js"; diff --git a/packages/binden/test/headers/content-encoding.test.ts b/packages/binden/test/headers/content-encoding.test.ts index 1fa52a99..f6f4a8f2 100644 --- a/packages/binden/test/headers/content-encoding.test.ts +++ b/packages/binden/test/headers/content-encoding.test.ts @@ -1,4 +1,5 @@ import { deepStrictEqual } from "node:assert"; +import { suite, test } from "node:test"; import { ContentEncoding } from "../../index.js"; diff --git a/packages/binden/test/headers/content-range.test.ts b/packages/binden/test/headers/content-range.test.ts index 4ad052c1..5eb9758f 100644 --- a/packages/binden/test/headers/content-range.test.ts +++ b/packages/binden/test/headers/content-range.test.ts @@ -1,4 +1,5 @@ import { deepStrictEqual, ok, throws } from "node:assert"; +import { suite, test } from "node:test"; import { ContentRange } from "../../index.js"; diff --git a/packages/binden/test/headers/content-type.test.ts b/packages/binden/test/headers/content-type.test.ts index 79a875e8..bfb05951 100644 --- a/packages/binden/test/headers/content-type.test.ts +++ b/packages/binden/test/headers/content-type.test.ts @@ -1,4 +1,5 @@ import { deepStrictEqual, ok, throws } from "node:assert"; +import { suite, test } from "node:test"; import { ContentType } from "../../index.js"; diff --git a/packages/binden/test/headers/cookie.test.ts b/packages/binden/test/headers/cookie.test.ts index 9c0a9c51..62f08d70 100644 --- a/packages/binden/test/headers/cookie.test.ts +++ b/packages/binden/test/headers/cookie.test.ts @@ -1,4 +1,5 @@ import { deepStrictEqual } from "node:assert"; +import { suite, test } from "node:test"; import { Cookie } from "../../index.js"; diff --git a/packages/binden/test/headers/forwarded.test.ts b/packages/binden/test/headers/forwarded.test.ts index a44d4e5a..e837ef87 100644 --- a/packages/binden/test/headers/forwarded.test.ts +++ b/packages/binden/test/headers/forwarded.test.ts @@ -1,4 +1,5 @@ import { deepStrictEqual } from "node:assert"; +import { suite, test } from "node:test"; import { Forwarded } from "../../index.js"; diff --git a/packages/binden/test/headers/if-modified-since.test.ts b/packages/binden/test/headers/if-modified-since.test.ts index 0ca6a1e5..9ec75a4d 100644 --- a/packages/binden/test/headers/if-modified-since.test.ts +++ b/packages/binden/test/headers/if-modified-since.test.ts @@ -1,4 +1,5 @@ import { deepStrictEqual, ok } from "node:assert"; +import { suite, test } from "node:test"; import { IfModifiedSince } from "../../index.js"; diff --git a/packages/binden/test/headers/range.test.ts b/packages/binden/test/headers/range.test.ts index 4dc785aa..98a1d1ef 100644 --- a/packages/binden/test/headers/range.test.ts +++ b/packages/binden/test/headers/range.test.ts @@ -1,4 +1,5 @@ import { deepStrictEqual } from "node:assert"; +import { suite, test } from "node:test"; import { Range } from "../../index.js"; diff --git a/packages/binden/test/middleware.test.ts b/packages/binden/test/middleware.test.ts index ef99975c..5424fb93 100644 --- a/packages/binden/test/middleware.test.ts +++ b/packages/binden/test/middleware.test.ts @@ -1,4 +1,5 @@ import { deepStrictEqual } from "node:assert"; +import { suite, test } from "node:test"; import { Middleware } from "../index.js"; diff --git a/packages/binden/test/request.test.ts b/packages/binden/test/request.test.ts index 2abe8d4b..ec2799fe 100644 --- a/packages/binden/test/request.test.ts +++ b/packages/binden/test/request.test.ts @@ -2,6 +2,7 @@ import { deepStrictEqual, ok } from "node:assert"; import { Server, createServer } from "node:http"; import { stringify, parse } from "node:querystring"; +import { afterEach, beforeEach, suite, test } from "node:test"; import fetch from "node-fetch"; import { @@ -18,7 +19,7 @@ const url = `http://localhost:${port}`; suite("BindenRequest", () => { let server: Server; - setup((done) => { + beforeEach((_t, done) => { server = createServer({ IncomingMessage: BindenRequest }).listen( port, done, @@ -358,5 +359,5 @@ suite("BindenRequest", () => { await serverPromise; }); - teardown((done) => server.close(done)); + afterEach((_t, done) => server.close(done)); }); diff --git a/packages/binden/test/response.test.ts b/packages/binden/test/response.test.ts index 7f33325c..2489bae2 100644 --- a/packages/binden/test/response.test.ts +++ b/packages/binden/test/response.test.ts @@ -7,6 +7,7 @@ import { tmpdir } from "node:os"; import fastJSON from "fast-json-stringify"; import fetch from "node-fetch"; import type { Stats } from "node:fs"; +import { afterEach, beforeEach, suite, test } from "node:test"; import { BindenResponse, @@ -29,7 +30,7 @@ suite("BindenResponse", () => { let file_stats: Stats; let dir_stats: Stats; - setup(async () => { + beforeEach(async () => { msg = Buffer.from(randomUUID()); await mkdir(dirPath); await writeFile(filePath, msg); @@ -611,7 +612,7 @@ suite("BindenResponse", () => { await serverPromise; }); - teardown(async () => { + afterEach(async () => { await rmdir(dirPath); await rm(filePath); await new Promise((resolve, reject) => { diff --git a/packages/binden/test/router.test.ts b/packages/binden/test/router.test.ts index 4de76381..637759a2 100644 --- a/packages/binden/test/router.test.ts +++ b/packages/binden/test/router.test.ts @@ -1,4 +1,5 @@ import { deepStrictEqual, throws } from "node:assert"; +import { suite, test } from "node:test"; import { Router, Middleware } from "../index.js"; diff --git a/packages/binden/test/setup.ts b/packages/binden/test/setup.ts new file mode 100644 index 00000000..5f3bc5e4 --- /dev/null +++ b/packages/binden/test/setup.ts @@ -0,0 +1,7 @@ +import http from "node:http"; + +// The suites start a fresh server on the same port before every test. node:test runs +// the next test without yielding to the I/O phase in between, so a keep-alive socket +// of the global agent can be reused after the previous server closed it (ECONNRESET). +// mocha yielded between tests, which hid this race; fresh connections avoid it. +http.globalAgent = new http.Agent({ keepAlive: false }); diff --git a/packages/body/test/index.test.ts b/packages/body/test/index.test.ts index 86952902..70c99ba0 100644 --- a/packages/body/test/index.test.ts +++ b/packages/body/test/index.test.ts @@ -1,17 +1,18 @@ /* eslint-disable init-declarations */ import { deepEqual, ok, fail, throws } from "node:assert/strict"; import { Server } from "node:http"; +import { afterEach, beforeEach, suite, test } from "node:test"; import { brotliCompress as brotliCompressAsync, gzip as gzipAsync, deflate as deflateAsync, - InputType, + type InputType, } from "node:zlib"; -import AJV, { JTDDataType } from "ajv/dist/jtd.js"; +import { Ajv as AJV, type JTDDataType } from "ajv/dist/jtd.js"; import { errors, request } from "undici"; import { Binden, Context, ct_text, ct_json, ct_form } from "binden"; -import { BodyParser, IParse } from "../index.js"; +import { BodyParser, type IParse } from "../index.js"; function brotliCompress(data: InputType): Promise { return new Promise((resolve, reject) => { @@ -56,7 +57,7 @@ suite("BodyParser", () => { let app: Binden; let server: Server; - setup((done) => { + beforeEach((_t, done) => { app = new Binden(); server = app.createServer().listen(port, done); }); @@ -291,7 +292,7 @@ suite("BodyParser", () => { deepEqual(text, ""); }); - teardown((done) => { + afterEach((_t, done) => { server.closeIdleConnections(); server.close(done); }); diff --git a/packages/body/test/setup.ts b/packages/body/test/setup.ts new file mode 100644 index 00000000..17d6d180 --- /dev/null +++ b/packages/body/test/setup.ts @@ -0,0 +1,8 @@ +import { Agent, setGlobalDispatcher } from "undici"; + +// The suite starts a fresh server on the same port before every test. node:test runs +// the next test without yielding to the I/O phase in between, so undici can reuse a +// pooled keep-alive connection to the previous (already closed) server: the request +// then fails before reaching the new server ("other side closed") and the test hangs. +// mocha yielded between tests, which hid this race; `pipelining: 0` disables keep-alive. +setGlobalDispatcher(new Agent({ pipelining: 0 })); diff --git a/packages/compression/test/compression.test.ts b/packages/compression/test/compression.test.ts index 34654cf0..fbb5a042 100644 --- a/packages/compression/test/compression.test.ts +++ b/packages/compression/test/compression.test.ts @@ -1,4 +1,4 @@ -/* eslint-disable init-declarations, @typescript-eslint/no-floating-promises, sort-imports */ +/* eslint-disable init-declarations, sort-imports */ import { deepStrictEqual, ok } from "node:assert"; import type { Server } from "node:http"; import { afterEach, beforeEach, describe, it } from "node:test"; diff --git a/packages/compression/test/setup.ts b/packages/compression/test/setup.ts new file mode 100644 index 00000000..17d6d180 --- /dev/null +++ b/packages/compression/test/setup.ts @@ -0,0 +1,8 @@ +import { Agent, setGlobalDispatcher } from "undici"; + +// The suite starts a fresh server on the same port before every test. node:test runs +// the next test without yielding to the I/O phase in between, so undici can reuse a +// pooled keep-alive connection to the previous (already closed) server: the request +// then fails before reaching the new server ("other side closed") and the test hangs. +// mocha yielded between tests, which hid this race; `pipelining: 0` disables keep-alive. +setGlobalDispatcher(new Agent({ pipelining: 0 })); diff --git a/packages/multipart/test/multipart.test.ts b/packages/multipart/test/multipart.test.ts index 157ff916..5a7c2e71 100644 --- a/packages/multipart/test/multipart.test.ts +++ b/packages/multipart/test/multipart.test.ts @@ -1,4 +1,4 @@ -/* eslint-disable init-declarations, @typescript-eslint/no-floating-promises, sort-imports */ +/* eslint-disable init-declarations, sort-imports */ import { deepEqual, ok } from "node:assert/strict"; import { request, Server } from "node:http"; import { afterEach, beforeEach, describe, it } from "node:test"; diff --git a/packages/multipart/test/setup.ts b/packages/multipart/test/setup.ts new file mode 100644 index 00000000..bd04fa70 --- /dev/null +++ b/packages/multipart/test/setup.ts @@ -0,0 +1,10 @@ +import { Agent, setGlobalDispatcher } from "undici"; +import http from "node:http"; + +// The suite starts a fresh server on the same port before every test. node:test runs +// the next test without yielding to the I/O phase in between, so a pooled keep-alive +// connection to the previous (already closed) server could be reused. Disable keep-alive +// for `node:http` requests and for `fetch` (the undici global dispatcher is shared with +// the built-in `fetch`). +http.globalAgent = new http.Agent({ keepAlive: false }); +setGlobalDispatcher(new Agent({ pipelining: 0 })); From 6b16f1ba44ac4c66629b97118c6ecbf991120d01 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 17:07:38 +0300 Subject: [PATCH 007/675] test: add @binden/logger tests `@binden/logger` had no tests. Cover the logger with `node:test`, so that every package source is part of the 100% coverage threshold. Co-Authored-By: Claude Opus 5.5 --- packages/logger/test/logger.test.ts | 156 ++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 packages/logger/test/logger.test.ts diff --git a/packages/logger/test/logger.test.ts b/packages/logger/test/logger.test.ts new file mode 100644 index 00000000..5e6ccf41 --- /dev/null +++ b/packages/logger/test/logger.test.ts @@ -0,0 +1,156 @@ +import { deepEqual, equal, ok } from "node:assert/strict"; +import { describe, it } from "node:test"; +import pino from "pino"; +import log, { Logger } from "../index.js"; + +type Line = Record; + +class Sink { + public readonly lines: Line[] = []; + + public write(chunk: string): void { + for (const line of chunk.split("\n")) { + if (line !== "") { + this.lines.push(JSON.parse(line) as Line); + } + } + } +} + +const methods = ["trace", "debug", "info", "warn", "error", "fatal"] as const; +const levels = { + trace: 10, + debug: 20, + info: 30, + warn: 40, + error: 50, + fatal: 60, +}; + +function withEnv(name: string, value: string | null, fn: () => void): void { + const previous = process.env[name] ?? null; + const set = (next: string | null): void => { + if (next === null) { + Reflect.deleteProperty(process.env, name); + } else { + process.env[name] = next; + } + }; + set(value); + try { + fn(); + } finally { + set(previous); + } +} + +describe("Logger", () => { + it(".ENV_VARIABLE_NAME", () => { + equal(Logger.ENV_VARIABLE_NAME, "BINDEN_LOG_LEVEL"); + }); + + it(".getLevel() defaults to `silent`", () => { + withEnv(Logger.ENV_VARIABLE_NAME, null, () => { + equal(Logger.getLevel(), "silent"); + }); + }); + + it(".getLevel() reads every level (case and whitespace insensitive)", () => { + for (const level of methods) { + withEnv(Logger.ENV_VARIABLE_NAME, ` ${level.toUpperCase()} `, () => { + equal(Logger.getLevel(), level); + }); + } + }); + + it(".getLevel() falls back to `silent` for unknown values", () => { + withEnv(Logger.ENV_VARIABLE_NAME, "verbose", () => { + equal(Logger.getLevel(), "silent"); + }); + }); + + it(".getLevel() reads a custom environment variable", () => { + withEnv("CUSTOM_BINDEN_LOG_LEVEL", "warn", () => { + equal(Logger.getLevel("CUSTOM_BINDEN_LOG_LEVEL"), "warn"); + }); + }); + + it("writes every level with the message and `extra`", () => { + const sink = new Sink(); + const logger = new Logger({ destination: sink, level: "trace" }); + + for (const method of methods) { + logger[method](`${method} message`, { method }); + logger[method](`${method} without extra`); + } + + deepEqual( + sink.lines.map(({ level, msg, method }) => [level, msg, method ?? null]), + methods.flatMap((method) => [ + [levels[method], `${method} message`, method], + [levels[method], `${method} without extra`, null], + ]), + ); + }); + + it("takes the level from the environment", () => { + withEnv(Logger.ENV_VARIABLE_NAME, "warn", () => { + const sink = new Sink(); + const logger = new Logger({ destination: sink }); + + logger.info("skipped"); + logger.warn("written"); + + deepEqual( + sink.lines.map(({ msg }) => msg), + ["written"], + ); + }); + }); + + it(".child() adds bindings", () => { + const sink = new Sink(); + const logger = new Logger({ destination: sink, level: "info" }); + + logger.child({ request_id: "id" }).info("with bindings"); + logger.child().info("without bindings"); + + const [withBindings, withoutBindings] = sink.lines; + equal(sink.lines.length, 2); + equal(withBindings.msg, "with bindings"); + equal(withBindings.request_id, "id"); + equal(withoutBindings.msg, "without bindings"); + ok(!("request_id" in withoutBindings)); + }); + + it("wraps an existing `pino` logger", () => { + const sink = new Sink(); + const logger = new Logger({ logger: pino({ level: "info" }, sink) }); + + logger.info("wrapped", { extra: true }); + + const [line] = sink.lines; + equal(sink.lines.length, 1); + equal(line.msg, "wrapped"); + equal(line.extra, true); + }); + + it("creates a `pino` logger without a destination", () => { + ok(new Logger() instanceof Logger); + ok(new Logger({ level: "silent" }) instanceof Logger); + ok(log instanceof Logger); + }); + + it("serializes errors", () => { + const sink = new Sink(); + const logger = new Logger({ destination: sink, level: "error" }); + + logger.error("failed", { error: new Error("boom") }); + + const [line] = sink.lines; + const error = line.error as Line; + equal(error.type, "Error"); + equal(error.message, "boom"); + equal(typeof error.stack, "string"); + }); +}); From 07e7471c6380eb031ffdc0b0cb52f8d4d459fe3e Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 17:07:38 +0300 Subject: [PATCH 008/675] ci: run one workflow for all workspaces Lint, pack and build the docs once (Node.js 24), test every workspace on Node.js 24 and 26, lint the commits of the pushed range, release all packages from `main` with semantic-release and deploy the TypeDoc site to GitHub Pages. Dependabot ignores the internal packages. Co-Authored-By: Claude Opus 5.5 --- .../ISSUE_TEMPLATE/bug_report.md | 2 +- .../ISSUE_TEMPLATE/feature_request.md | 0 .../PULL_REQUEST_TEMPLATE.md | 0 .../binden/.github => .github}/dependabot.yml | 4 + .../binden/.github => .github}/funding.yml | 0 .github/workflows/ci.yml | 179 ++++++++++++++++++ packages/binden/.github/workflows/ci.yml | 110 ----------- 7 files changed, 184 insertions(+), 111 deletions(-) rename {packages/binden/.github => .github}/ISSUE_TEMPLATE/bug_report.md (90%) rename {packages/binden/.github => .github}/ISSUE_TEMPLATE/feature_request.md (100%) rename {packages/binden/.github => .github}/PULL_REQUEST_TEMPLATE/PULL_REQUEST_TEMPLATE.md (100%) rename {packages/binden/.github => .github}/dependabot.yml (78%) rename {packages/binden/.github => .github}/funding.yml (100%) create mode 100644 .github/workflows/ci.yml delete mode 100644 packages/binden/.github/workflows/ci.yml diff --git a/packages/binden/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md similarity index 90% rename from packages/binden/.github/ISSUE_TEMPLATE/bug_report.md rename to .github/ISSUE_TEMPLATE/bug_report.md index 2cf2dfe2..1e4253e9 100644 --- a/packages/binden/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -23,7 +23,7 @@ A clear and concise description of what you expected to happen. - Node.js version: [e.g. v16.4.2] - NPM version: [e.g. 16.4.2] -- `binden` version [e.g. 1.0.0] +- Package and version [e.g. `binden` 4.0.0, `@binden/body` 4.0.0] **Additional context** Add any other context about the problem here. diff --git a/packages/binden/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md similarity index 100% rename from packages/binden/.github/ISSUE_TEMPLATE/feature_request.md rename to .github/ISSUE_TEMPLATE/feature_request.md diff --git a/packages/binden/.github/PULL_REQUEST_TEMPLATE/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE/PULL_REQUEST_TEMPLATE.md similarity index 100% rename from packages/binden/.github/PULL_REQUEST_TEMPLATE/PULL_REQUEST_TEMPLATE.md rename to .github/PULL_REQUEST_TEMPLATE/PULL_REQUEST_TEMPLATE.md diff --git a/packages/binden/.github/dependabot.yml b/.github/dependabot.yml similarity index 78% rename from packages/binden/.github/dependabot.yml rename to .github/dependabot.yml index 9367d5c0..18b51b19 100644 --- a/packages/binden/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,6 +7,10 @@ updates: interval: daily allow: - dependency-type: direct + # Workspace packages are released in lockstep by scripts/release.mjs + ignore: + - dependency-name: binden + - dependency-name: "@binden/*" assignees: - vansergen versioning-strategy: increase diff --git a/packages/binden/.github/funding.yml b/.github/funding.yml similarity index 100% rename from packages/binden/.github/funding.yml rename to .github/funding.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..05f1c031 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,179 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +# Outdated pull request runs are cancelled. Runs on `main` are never cancelled, +# so a release cannot be interrupted half-way (tag pushed, packages partially published). +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + lint: + runs-on: ubuntu-latest + + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v7 + with: + node-version: 24 + check-latest: true + cache: npm + + - name: Install dependencies and build the workspaces + run: npm ci + + - name: Lockstep versions + run: npm run release:check + + - name: Prettier + run: npm run prettier + + - name: commitlint + run: npx commitlint --verbose --from "$FROM" --to "$TO" + env: + FROM: ${{ github.event.pull_request.base.sha || github.event.before }} + TO: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: ESLint + run: npm run eslint + + - name: Pack (test) + run: npm run pack:check + + - name: Docs + run: npm run docs:build + + - name: Upload docs + if: github.event_name == 'push' + uses: actions/upload-pages-artifact@v5 + with: + path: docs + + test: + runs-on: ubuntu-latest + + timeout-minutes: 15 + + strategy: + fail-fast: false + matrix: + node-version: [24, 26] + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + check-latest: true + cache: npm + + - name: Install dependencies and build the workspaces + run: npm ci + + - name: Tests (with coverage) + run: npm run coverage + + - name: Coveralls + if: matrix.node-version == 24 + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + file: coverage/lcov.info + + release: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + needs: [lint, test] + + runs-on: ubuntu-latest + + timeout-minutes: 30 + + permissions: + contents: write + issues: write + pull-requests: write + id-token: write + + env: + HUSKY: 0 + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v7 + with: + node-version: 24 + check-latest: true + registry-url: https://registry.npmjs.org + + - name: Import GPG key + run: echo $GPG_KEY | base64 --decode | gpg --batch --import + env: + GPG_KEY: ${{ secrets.GPG_KEY }} + + - name: Add the custom gpg executable + run: | + rm -rf /tmp/gpg.sh + echo 'gpg --batch --pinentry-mode=loopback --passphrase $GPG_KEY_PASSPHRASE $@' >> /tmp/gpg.sh + chmod +x /tmp/gpg.sh + + - name: Setup git + run: | + git config commit.gpgsign true + git config user.signingkey $GPG_KEY_ID + git config gpg.program /tmp/gpg.sh + env: + GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }} + + - name: Install dependencies and build the workspaces + run: npm ci + + # Publishes every workspace with the same version (see scripts/release.mjs). + # npm uses trusted publishing (OIDC) when it is configured for a package and + # falls back to NODE_AUTH_TOKEN otherwise. + - name: Release + run: npx semantic-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GIT_COMMITTER_EMAIL: ${{ secrets.GIT_COMMITTER_EMAIL }} + GIT_COMMITTER_NAME: ${{ secrets.GIT_COMMITTER_NAME }} + GPG_KEY_PASSPHRASE: ${{ secrets.GPG_KEY_PASSPHRASE }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: true + + pages: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + # The documentation artifact is uploaded by the `lint` job. + needs: [release] + + runs-on: ubuntu-latest + + permissions: + pages: write + id-token: write + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@v5 diff --git a/packages/binden/.github/workflows/ci.yml b/packages/binden/.github/workflows/ci.yml deleted file mode 100644 index 0630c03a..00000000 --- a/packages/binden/.github/workflows/ci.yml +++ /dev/null @@ -1,110 +0,0 @@ -name: CI - -on: push - -jobs: - test: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [20, 22] - - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - uses: actions/setup-node@v7 - with: - node-version: ${{ matrix.node-version }} - check-latest: true - - - name: Install dependencies - run: npm ci - - - name: Prettier - run: npm run prettier - - - name: ESLint - run: npm run eslint - - - name: commitlint - run: npm run commitlint - - - name: Tests - run: npm test - - - name: Publish (test) - run: npm publish --dry-run - - - name: Docs - run: npm run docs:build - - - name: Coverage - run: npm run coverage - - - name: Coveralls - uses: coverallsapp/github-action@master - with: - github-token: ${{ secrets.github_token }} - flag-name: Node.js v${{ matrix.node-version }} - parallel: true - - coverage: - needs: [test] - - runs-on: ubuntu-latest - - steps: - - name: Coveralls Finished - uses: coverallsapp/github-action@master - with: - github-token: ${{ secrets.github_token }} - parallel-finished: true - - release: - if: ${{ github.ref == 'refs/heads/main' }} - - needs: [test, coverage] - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v7 - - - uses: actions/setup-node@v7 - with: - node-version: 22 - check-latest: true - - - name: Import GPG key - run: echo $GPG_KEY | base64 --decode | gpg --batch --import - env: - GPG_KEY: ${{ secrets.GPG_KEY }} - - - name: Add the custom gpg executable - run: | - rm -rf /tmp/gpg.sh - echo 'gpg --batch --pinentry-mode=loopback --passphrase $GPG_KEY_PASSPHRASE $@' >> /tmp/gpg.sh - chmod +x /tmp/gpg.sh - - - name: Setup git - run: | - git config commit.gpgsign true - git config user.signingkey $GPG_KEY_ID - git config gpg.program /tmp/gpg.sh - env: - GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }} - - - name: Install dependencies - run: npm ci - - - name: Release - run: npx semantic-release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GIT_COMMITTER_EMAIL: ${{ secrets.GIT_COMMITTER_EMAIL }} - GIT_COMMITTER_NAME: ${{ secrets.GIT_COMMITTER_NAME }} - GPG_KEY_PASSPHRASE: ${{ secrets.GPG_KEY_PASSPHRASE }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} From 93e7fb4bc4895f568ecee16932a39b23d03b4380 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 17:07:39 +0300 Subject: [PATCH 009/675] docs: describe the monorepo in the READMEs Co-Authored-By: Claude Opus 5.5 --- README.md | 53 ++++++++++++++++++++++++++++++++++ packages/binden/README.md | 10 ++----- packages/body/README.md | 8 +---- packages/compression/README.md | 14 +-------- packages/logger/README.md | 2 +- packages/multipart/README.md | 8 +---- 6 files changed, 59 insertions(+), 36 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 00000000..0afa630e --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# binden [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) + +A simple HTTP server framework and its official packages. All packages live in this repository as +[npm workspaces](https://docs.npmjs.com/cli/using-npm/workspaces) and are released together with +the same version. + +| Package | Description | License | +| --------------------------------------------- | --------------------------------------- | ------------- | +| [`binden`](packages/binden) | A simple HTTP server framework | MIT | +| [`@binden/logger`](packages/logger) | A simple logger (based on `pino`) | MIT | +| [`@binden/body`](packages/body) | Body parser middleware | AGPL-3.0-only | +| [`@binden/compression`](packages/compression) | Compression middleware | AGPL-3.0-only | +| [`@binden/multipart`](packages/multipart) | `multipart/form-data` parser middleware | AGPL-3.0-only | + +API documentation: + +## Development + +Requires Node.js `>=24.15.0` and npm `>=11.11.0`. + +```bash +npm install # installs all workspaces and builds them (`prepare`) +npm run build # type checks and compiles every workspace (including tests) +npm test # node:test (via tsx) in every workspace +npm run coverage # tests with c8 coverage (coverage/lcov.info) +npm run eslint # type-aware ESLint for the whole repository +npm run docs:build # TypeDoc for all packages (docs/) +npm run ci:all # everything CI runs +``` + +Commands for a single package: `npm test -w packages/binden`. + +Packages consume each other through the compiled `dist/` (`@binden/body`, `@binden/compression` and +`@binden/multipart` import `binden`, which imports `@binden/logger`): run `npm run build` after +changing a package that others depend on. + +## Releases + +Releases are fully automated by [semantic-release](https://github.com/semantic-release/semantic-release) +on every push to `main` (see [`.releaserc.json`](.releaserc.json)): + +- the next version is derived from [Conventional Commits](https://conventionalcommits.org) since the + last `v*` tag; +- [`scripts/release.mjs`](scripts/release.mjs) sets that version in **every** workspace (and the + internal dependency ranges to `^`) and publishes all packages to npm in dependency order; +- the release commit updates [`CHANGELOG.md`](CHANGELOG.md) for all packages. + +`npm run release:check` verifies that all workspaces share one version. If a release fails after +some packages were published, `node scripts/release.mjs publish --skip-existing` publishes the +rest. + +The `CHANGELOG.md` files inside `packages/*` describe the history before the packages were merged +into this repository. diff --git a/packages/binden/README.md b/packages/binden/README.md index 4ca9035b..0716901e 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -1,4 +1,4 @@ -# Binden ![CI Status](https://github.com/binden-js/binden/workflows/CI/badge.svg) [![version](https://img.shields.io/github/package-json/v/binden-js/binden?style=plastic)](https://github.com/binden-js/binden) [![Known Vulnerabilities](https://snyk.io/test/github/binden-js/binden/badge.svg)](https://snyk.io/test/github/binden-js/binden) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![GitHub top language](https://img.shields.io/github/languages/top/binden-js/binden) ![node version](https://img.shields.io/node/v/binden) ![npm downloads](https://img.shields.io/npm/dt/binden) ![License](https://img.shields.io/github/license/binden-js/binden) +# Binden [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![version](https://img.shields.io/npm/v/binden?style=plastic)](https://www.npmjs.com/package/binden) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/binden-js/binden/blob/main/CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![node version](https://img.shields.io/node/v/binden) ![npm downloads](https://img.shields.io/npm/dt/binden) [![License](https://img.shields.io/npm/l/binden)](LICENSE) A simple server framework (written in [TypeScript](https://www.typescriptlang.org/)). @@ -57,7 +57,7 @@ const secureServer = app.createSecureServer({ key, cert }); ### Context -- `.log` - get the logger (see [`@binden/logger`](https://github.com/binden-js/logger)) +- `.log` - get the logger (see [`@binden/logger`](https://github.com/binden-js/binden/tree/main/packages/logger)) ```typescript const { log } = context; @@ -605,9 +605,3 @@ const range = Range.fromString(request.headers.range); // or using BindenRequest const { range } = request; ``` - -### Test - -```bash -npm run test:ci -``` diff --git a/packages/body/README.md b/packages/body/README.md index de6be719..d956ace0 100644 --- a/packages/body/README.md +++ b/packages/body/README.md @@ -1,4 +1,4 @@ -# @binden/body ![CI Status](https://github.com/binden-js/body/workflows/CI/badge.svg) [![version](https://img.shields.io/github/package-json/v/binden-js/body?style=plastic)](https://github.com/binden-js/body) [![Known Vulnerabilities](https://snyk.io/test/github/binden-js/body/badge.svg)](https://snyk.io/test/github/binden-js/body) [![Coverage Status](https://coveralls.io/repos/github/binden-js/body/badge.svg?branch=main)](https://coveralls.io/github/binden-js/body?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![GitHub top language](https://img.shields.io/github/languages/top/binden-js/body) ![node version](https://img.shields.io/node/v/@binden/body) ![npm downloads](https://img.shields.io/npm/dt/@binden/body) ![License](https://img.shields.io/github/license/binden-js/body) +# @binden/body [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![version](https://img.shields.io/npm/v/@binden/body?style=plastic)](https://www.npmjs.com/package/@binden/body) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/binden-js/binden/blob/main/CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![node version](https://img.shields.io/node/v/@binden/body) ![npm downloads](https://img.shields.io/npm/dt/@binden/body) [![License](https://img.shields.io/npm/l/@binden/body)](LICENSE) [Binden](https://github.com/binden-js/binden) body parser middleware. Supports the following [content types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) @@ -59,9 +59,3 @@ new Binden() console.log(`Server is listening on the port ${port}`); }); ``` - -### Test - -```bash -npm test -``` diff --git a/packages/compression/README.md b/packages/compression/README.md index 0a81ed46..b936186c 100644 --- a/packages/compression/README.md +++ b/packages/compression/README.md @@ -1,4 +1,4 @@ -# @binden/compression ![CI Status](https://github.com/binden-js/compression/workflows/CI/badge.svg) [![version](https://img.shields.io/github/package-json/v/binden-js/compression?style=plastic)](https://github.com/binden-js/compression/releases) [![Known Vulnerabilities](https://snyk.io/test/github/binden-js/compression/badge.svg)](https://snyk.io/test/github/binden-js/compression) [![Coverage Status](https://coveralls.io/repos/github/binden-js/compression/badge.svg?branch=main)](https://coveralls.io/github/binden-js/compression?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![GitHub top language](https://img.shields.io/github/languages/top/binden-js/compression) ![node version](https://img.shields.io/node/v/@binden/compression) ![npm downloads](https://img.shields.io/npm/dt/@binden/compression) [![License](https://img.shields.io/github/license/binden-js/compression)](LICENSE) +# @binden/compression [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![version](https://img.shields.io/npm/v/@binden/compression?style=plastic)](https://www.npmjs.com/package/@binden/compression) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/binden-js/binden/blob/main/CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![node version](https://img.shields.io/node/v/@binden/compression) ![npm downloads](https://img.shields.io/npm/dt/@binden/compression) [![License](https://img.shields.io/npm/l/@binden/compression)](LICENSE) [Binden](https://github.com/binden-js/binden) compression middleware (supports `br`, `gzip` and `deflate`). @@ -15,15 +15,3 @@ import Compression from "@binden/compression"; app.use(Compression); ``` - -### Test - -```bash -npm test -``` - -### Coverage - -```bash -npm run coverage -``` diff --git a/packages/logger/README.md b/packages/logger/README.md index f4010ada..1dd6bc7c 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -1,4 +1,4 @@ -# @binden/logger ![CI Status](https://github.com/binden-js/logger/workflows/CI/badge.svg) [![version](https://img.shields.io/github/package-json/v/binden-js/logger?style=plastic)](https://github.com/binden-js/logger/releases) [![Known Vulnerabilities](https://snyk.io/test/github/binden-js/logger/badge.svg)](https://snyk.io/test/github/binden-js/logger) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![GitHub top language](https://img.shields.io/github/languages/top/binden-js/logger) ![node version](https://img.shields.io/node/v/@binden/logger) ![npm downloads](https://img.shields.io/npm/dt/@binden/logger) [![License](https://img.shields.io/github/license/binden-js/logger)](LICENSE) +# @binden/logger [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![version](https://img.shields.io/npm/v/@binden/logger?style=plastic)](https://www.npmjs.com/package/@binden/logger) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/binden-js/binden/blob/main/CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![node version](https://img.shields.io/node/v/@binden/logger) ![npm downloads](https://img.shields.io/npm/dt/@binden/logger) [![License](https://img.shields.io/npm/l/@binden/logger)](LICENSE) A simple logger (based on [`pino`](https://github.com/pinojs/pino)). diff --git a/packages/multipart/README.md b/packages/multipart/README.md index 35d2d2af..d01f7911 100644 --- a/packages/multipart/README.md +++ b/packages/multipart/README.md @@ -1,4 +1,4 @@ -# @binden/multipart ![CI Status](https://github.com/binden-js/multipart/workflows/CI/badge.svg) [![version](https://img.shields.io/github/package-json/v/binden-js/multipart?style=plastic)](https://github.com/binden-js/multipart) [![Known Vulnerabilities](https://snyk.io/test/github/binden-js/multipart/badge.svg)](https://snyk.io/test/github/binden-js/multipart) [![Coverage Status](https://coveralls.io/repos/github/binden-js/multipart/badge.svg?branch=main)](https://coveralls.io/github/binden-js/multipart?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![GitHub top language](https://img.shields.io/github/languages/top/binden-js/multipart) ![node version](https://img.shields.io/node/v/@binden/multipart) ![npm downloads](https://img.shields.io/npm/dt/@binden/multipart) ![License](https://img.shields.io/github/license/binden-js/multipart) +# @binden/multipart [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![version](https://img.shields.io/npm/v/@binden/multipart?style=plastic)](https://www.npmjs.com/package/@binden/multipart) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/binden-js/binden/blob/main/CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![node version](https://img.shields.io/node/v/@binden/multipart) ![npm downloads](https://img.shields.io/npm/dt/@binden/multipart) [![License](https://img.shields.io/npm/l/@binden/multipart)](LICENSE) [Binden](https://github.com/binden-js/binden) `multipart/form-data` parser middleware (based on [busboy](https://github.com/mscdex/busboy)). @@ -61,9 +61,3 @@ const next = (context) => { app.use("/formdata", new Router().post(formDataParser, next)); ``` - -### Test - -```bash -npm test -``` From 40057ec12cfd39621e3c3525af6782e9a4b20f9c Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 18:20:41 +0300 Subject: [PATCH 010/675] fix: keep compressed responses consistent in @binden/compression Compression now starts with the first body chunk written through the middleware instead of setting `Content-Encoding` up front: - the `Content-Length` of the uncompressed body (set by `sendFile`) is removed, so compressed files are no longer cut off by the client; - responses written past the middleware (e.g. by the error handler after a later middleware throws) are no longer labelled with a `Content-Encoding` they do not have; - nothing is compressed once the headers have already been sent. Content codings are listed in the order they are applied. Co-Authored-By: Claude Opus 5.5 --- packages/compression/index.ts | 43 +++++++++---- packages/compression/test/compression.test.ts | 63 ++++++++++++++++++- 2 files changed, 93 insertions(+), 13 deletions(-) diff --git a/packages/compression/index.ts b/packages/compression/index.ts index 53ee66f9..ba72a1e9 100644 --- a/packages/compression/index.ts +++ b/packages/compression/index.ts @@ -30,15 +30,6 @@ export class Compression extends Middleware { const { response } = context; - const raw = response.getHeader("Content-Encoding"); - - response.setHeader( - "Content-Encoding", - typeof raw === "undefined" - ? format - : [format, ...(Array.isArray(raw) ? raw : [`${raw}`])], - ); - const compress = format === "br" ? createBrotliCompress() @@ -46,7 +37,27 @@ export class Compression extends Middleware { ? createDeflate() : createGzip(); - compress.pipe(response); + // Compression starts with the first body chunk written through this middleware, + // so responses written past it (e.g. by the error handler) stay unencoded, and + // a `Content-Length` of the uncompressed body (e.g. from `sendFile`) is dropped. + let active: boolean | null = null; + const activate = (): boolean => { + if (active === null) { + active = !response.headersSent; + if (active) { + const raw = response.getHeader("Content-Encoding"); + response.setHeader( + "Content-Encoding", + typeof raw === "undefined" + ? format + : [...(Array.isArray(raw) ? raw : [`${raw}`]), format], + ); + response.removeHeader("Content-Length"); + compress.pipe(response); + } + } + return active; + }; const proxyResponse = new Proxy(response, { get(_r, p: string): unknown { @@ -56,7 +67,11 @@ export class Compression extends Middleware { encoding: BufferEncoding, cb?: (error?: Error | null) => void, ): void { - compress.write(data, encoding, cb); + if (activate()) { + compress.write(data, encoding, cb); + } else { + response.write(data, encoding, cb); + } }; } else if (p === "end") { return function end( @@ -64,7 +79,11 @@ export class Compression extends Middleware { encoding: BufferEncoding, cb?: () => void, ): void { - compress.end(data, encoding, cb); + if (activate()) { + compress.end(data, encoding, cb); + } else { + response.end(data, encoding, cb); + } }; } diff --git a/packages/compression/test/compression.test.ts b/packages/compression/test/compression.test.ts index fbb5a042..23c37da2 100644 --- a/packages/compression/test/compression.test.ts +++ b/packages/compression/test/compression.test.ts @@ -1,5 +1,9 @@ /* eslint-disable init-declarations, sort-imports */ import { deepStrictEqual, ok } from "node:assert"; +import { randomBytes, randomUUID } from "node:crypto"; +import { rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { Server } from "node:http"; import { afterEach, beforeEach, describe, it } from "node:test"; import { @@ -9,7 +13,7 @@ import { inflate as inflateAsync, } from "node:zlib"; -import { Binden, Context, Middleware } from "binden"; +import { Binden, BindenError, Context, Middleware } from "binden"; import { request } from "undici"; import { @@ -286,6 +290,63 @@ describe("Compression", () => { deepStrictEqual(actual, expected); }); + it( + "sendFile (drops the `Content-Length` of the uncompressed file)", + { timeout: 10_000 }, + async () => { + const expected = randomBytes(1024).toString("hex"); + const path = join(tmpdir(), `binden-compression-${randomUUID()}.txt`); + await writeFile(path, expected); + + try { + app.use(new Compression({ format: "gzip" }), (ct: Context) => + ct.sendFile(path), + ); + + const response = await request(url); + + deepStrictEqual(response.statusCode, 200); + deepStrictEqual(response.headers["content-encoding"], "gzip"); + ok(typeof response.headers["content-length"] === "undefined"); + + const body = await response.body.arrayBuffer(); + const actual = (await gunzip(Buffer.from(body))).toString(); + + deepStrictEqual(actual, expected); + } finally { + await rm(path); + } + }, + ); + + it("does not encode error responses of the following middlewares", async () => { + const message = "invalid request"; + app.use(new Compression({ format: "gzip" }), () => { + throw new BindenError(400, { expose: true, message }); + }); + + const response = await request(url); + + deepStrictEqual(response.statusCode, 400); + ok(typeof response.headers["content-encoding"] === "undefined"); + deepStrictEqual(await response.body.text(), message); + }); + + it("does not compress when the headers have already been sent", async () => { + const expected = "Hello World"; + app.use(new Compression({ format: "gzip" }), (ct: Context) => { + ct.response.flushHeaders(); + ct.response.write("Hello "); + return ct.send("World"); + }); + + const response = await request(url); + + deepStrictEqual(response.statusCode, 200); + ok(typeof response.headers["content-encoding"] === "undefined"); + deepStrictEqual(await response.body.text(), expected); + }); + afterEach(async () => { await new Promise((resolve, reject) => { server.closeIdleConnections(); From 077c0176ad3bc198dcebed817c554519264a7d0b Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 18:22:00 +0300 Subject: [PATCH 011/675] fix: follow the Accept-Encoding negotiation rules in @binden/compression - codings refused with `q=0` are never selected, and `*` picks the first coding that is not refused; - an empty `Accept-Encoding` (or one refusing every supported coding) leaves the response unencoded, while a missing header still selects the default coding; - the `auto` format adds `Vary: Accept-Encoding` (keeping existing `Vary` values), so caches do not reuse a response encoded for another client. Co-Authored-By: Claude Opus 5.5 --- packages/compression/index.ts | 50 +++++++++- packages/compression/test/compression.test.ts | 95 ++++++++++++++++++- 2 files changed, 138 insertions(+), 7 deletions(-) diff --git a/packages/compression/index.ts b/packages/compression/index.ts index ba72a1e9..faccf4bc 100644 --- a/packages/compression/index.ts +++ b/packages/compression/index.ts @@ -22,6 +22,10 @@ export class Compression extends Middleware { } public run(context: Context): Context { + if (this.format === "auto") { + Compression.#varyOnAcceptEncoding(context); + } + const format = Compression.#getFormat(context, this.format); if (format === "identity") { @@ -115,17 +119,33 @@ export class Compression extends Middleware { return format; } - const { accept_encoding } = context.request; + const { request } = context; - if (!accept_encoding.length) { + // No `Accept-Encoding` means that any coding is acceptable, while an empty + // one (or one refusing everything with `q=0`) asks for no coding at all. + if (typeof request.headers["accept-encoding"] === "undefined") { return DefaultCompression; } - for (const { encoding } of accept_encoding) { + const { accept_encoding } = request; + const refused = new Set( + accept_encoding + .filter(({ q_value }) => q_value === 0) + .map(({ encoding }) => encoding), + ); + + for (const { encoding } of accept_encoding.filter( + ({ q_value }) => q_value !== 0, + )) { /* eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check */ switch (encoding) { - case "*": - return DefaultCompression; + case "*": { + const any = Compression.#formats.find((f) => !refused.has(f)); + if (typeof any !== "undefined") { + return any; + } + break; + } case "identity": return "identity"; case "x-gzip": @@ -141,6 +161,26 @@ export class Compression extends Middleware { return "identity"; } + + /** Response codings in the order of preference (the first one is the default) */ + static readonly #formats = [DefaultCompression, "gzip", "deflate"] as const; + + /** The response depends on `Accept-Encoding` (keeps the existing `Vary` values) */ + static #varyOnAcceptEncoding({ response }: Context): void { + const raw = response.getHeader("Vary"); + const values = ( + Array.isArray(raw) ? raw : typeof raw === "undefined" ? [] : [`${raw}`] + ) + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter((value) => value !== ""); + + if ( + !values.some((v) => v === "*" || v.toLowerCase() === "accept-encoding") + ) { + response.setHeader("Vary", [...values, "Accept-Encoding"].join(", ")); + } + } } export default new Compression(); diff --git a/packages/compression/test/compression.test.ts b/packages/compression/test/compression.test.ts index 23c37da2..cb0a2e37 100644 --- a/packages/compression/test/compression.test.ts +++ b/packages/compression/test/compression.test.ts @@ -189,8 +189,7 @@ describe("Compression", () => { const expected = "Hello World"; app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); - const headers = { "Accept-Encoding": "" }; - const response = await request(url, { headers }); + const response = await request(url); deepStrictEqual(response.headers["content-encoding"], DefaultCompression); deepStrictEqual(response.statusCode, 200); @@ -201,6 +200,98 @@ describe("Compression", () => { deepStrictEqual(actual, expected); }); + it("auto (empty `Accept-Encoding` asks for no coding)", async () => { + const expected = "Hello World"; + app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); + + const headers = { "Accept-Encoding": "" }; + const response = await request(url, { headers }); + + ok(typeof response.headers["content-encoding"] === "undefined"); + deepStrictEqual(response.headers.vary, "Accept-Encoding"); + deepStrictEqual(await response.body.text(), expected); + }); + + it("auto (codings refused with `q=0`)", async () => { + const expected = "Hello World"; + app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); + + const headers = { "Accept-Encoding": "gzip;q=0, br;q=0, deflate;q=0" }; + const response = await request(url, { headers }); + + ok(typeof response.headers["content-encoding"] === "undefined"); + deepStrictEqual(await response.body.text(), expected); + }); + + it("auto (`*` skips codings refused with `q=0`)", async () => { + const expected = "Hello World"; + app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); + + const headers = { "Accept-Encoding": "br;q=0, *" }; + const response = await request(url, { headers }); + + deepStrictEqual(response.headers["content-encoding"], "gzip"); + + const body = await response.body.arrayBuffer(); + const actual = (await gunzip(Buffer.from(body))).toString(); + + deepStrictEqual(actual, expected); + }); + + it("auto (`*` with every coding refused)", async () => { + const expected = "Hello World"; + app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); + + const headers = { "Accept-Encoding": "br;q=0, gzip;q=0, deflate;q=0, *" }; + const response = await request(url, { headers }); + + ok(typeof response.headers["content-encoding"] === "undefined"); + deepStrictEqual(await response.body.text(), expected); + }); + + it("auto (adds `Accept-Encoding` to the existing `Vary`)", async () => { + const expected = "Hello World"; + app.use( + (ct: Context) => { + ct.response.setHeader("Vary", "Origin"); + }, + new Compression({ format: "auto" }), + new TestMiddleware(expected), + ); + + const headers = { "Accept-Encoding": "gzip" }; + const response = await request(url, { headers }); + + deepStrictEqual(response.headers.vary, "Origin, Accept-Encoding"); + deepStrictEqual(response.headers["content-encoding"], "gzip"); + }); + + it("auto (keeps a `Vary` that already covers `Accept-Encoding`)", async () => { + const expected = "Hello World"; + app.use( + (ct: Context) => { + ct.response.setHeader("Vary", ["Origin", "accept-encoding"]); + }, + new Compression({ format: "auto" }), + new TestMiddleware(expected), + ); + + const headers = { "Accept-Encoding": "gzip" }; + const response = await request(url, { headers }); + + deepStrictEqual(response.headers.vary, ["Origin", "accept-encoding"]); + }); + + it("does not add `Vary` for a fixed format", async () => { + const expected = "Hello World"; + app.use(new Compression({ format: "gzip" }), new TestMiddleware(expected)); + + const response = await request(url); + + ok(typeof response.headers.vary === "undefined"); + deepStrictEqual(response.headers["content-encoding"], "gzip"); + }); + it("auto (unsupported)", async () => { const expected = "Hello World"; const format = "compress"; From 5c11923596d6fd626cdb65e6271c0547b4846b27 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 18:23:25 +0300 Subject: [PATCH 012/675] fix: settle BodyParser when the request is aborted in @binden/body `pipe` does not forward an aborted request to the decoders, so a compressed upload interrupted by the client left `BodyParser.run()` pending forever (and the following middlewares and cleanup never ran). The decoders are now destroyed when the request closes prematurely, which rejects the parsing promise once. Co-Authored-By: Claude Opus 5.5 --- packages/body/index.ts | 11 +++++ packages/body/test/abort.test.ts | 74 ++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 packages/body/test/abort.test.ts diff --git a/packages/body/index.ts b/packages/body/index.ts index 9bc5b8e5..39d43ff1 100644 --- a/packages/body/index.ts +++ b/packages/body/index.ts @@ -73,6 +73,7 @@ export class BodyParser extends Middleware { const body = await new Promise((resolve, reject) => { let { request: stream } = context as { request: Readable }; + const source = stream; for (const [encoding, decompresser] of streams) { decompresser.once("error", (error) => { @@ -83,6 +84,16 @@ export class BodyParser extends Middleware { stream = decompresser; } + // `pipe` does not forward an aborted request to the decoders: destroy them, + // so that the last one finishes (with a premature close) and settles this promise + finished(source, (error) => { + if (error) { + for (const [, decompresser] of streams) { + decompresser.destroy(); + } + } + }); + const chunks: Buffer[] = []; stream.on("data", (data: Buffer) => chunks.push(data)); diff --git a/packages/body/test/abort.test.ts b/packages/body/test/abort.test.ts new file mode 100644 index 00000000..683e64b8 --- /dev/null +++ b/packages/body/test/abort.test.ts @@ -0,0 +1,74 @@ +/* eslint-disable init-declarations */ +import { ok } from "node:assert/strict"; +import { type Server, request } from "node:http"; +import { afterEach, beforeEach, suite, test } from "node:test"; +import { gzipSync } from "node:zlib"; +import { Binden, type Context, ct_text } from "binden"; + +import { BodyParser } from "../index.js"; + +const port = 8080; + +suite("BodyParser (aborted request)", () => { + let server: Server; + let started: Promise; + let settled: Promise; + + beforeEach((_t, done) => { + let start: () => void; + let settle: (value: unknown) => void; + started = new Promise((resolve) => { + start = resolve; + }); + settled = new Promise((resolve) => { + settle = resolve; + }); + + const parser = new BodyParser(); + const app = new Binden().use(async (ct: Context) => { + start(); + try { + await parser.run(ct); + settle(null); + } catch (error: unknown) { + settle(error); + } + }); + + server = app.createServer().listen(port, done); + }); + + test( + "settles when a compressed upload is aborted", + { timeout: 5000 }, + async () => { + const body = gzipSync("Hello World".repeat(10_000)); + const req = request({ + port, + method: "POST", + headers: { + "Content-Type": ct_text, + "Content-Encoding": "gzip", + "Content-Length": body.length, + }, + }); + req.on("error", () => { + // The request is aborted on purpose + }); + req.write(body.subarray(0, body.length / 2)); + + await started; + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + req.destroy(); + + ok((await settled) instanceof Error); + }, + ); + + afterEach((_t, done) => { + server.closeAllConnections(); + server.close(done); + }); +}); From f1c65e9dcf8a770fb206f776f229e5332d4241a2 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 18:24:21 +0300 Subject: [PATCH 013/675] fix: handle file stream errors in @binden/multipart A file part that was cut off (e.g. a request body without the closing boundary) made the busboy file stream emit an `error` nobody listened to, which crashed the Node.js process. The error is now handled like the other parsing errors and the request is rejected with `415`. Co-Authored-By: Claude Opus 5.5 --- packages/multipart/index.ts | 4 ++ packages/multipart/test/malformed.test.ts | 78 +++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 packages/multipart/test/malformed.test.ts diff --git a/packages/multipart/index.ts b/packages/multipart/index.ts index fda5b166..824c83a9 100644 --- a/packages/multipart/index.ts +++ b/packages/multipart/index.ts @@ -70,6 +70,10 @@ export class Multipart extends Middleware { .on("file", (name, stream, { mimeType: type, filename }) => { const chunks: Buffer[] = []; stream + .once("error", (cause) => { + const message = "`busboy` file stream error"; + reject(new BindenError(415, { message, cause })); + }) .once("close", () => { // @types/busboy 1.5.5 types `filename` as optional; the value is passed through unchanged fd.append(name, new File(chunks, filename as string, { type })); diff --git a/packages/multipart/test/malformed.test.ts b/packages/multipart/test/malformed.test.ts new file mode 100644 index 00000000..92237471 --- /dev/null +++ b/packages/multipart/test/malformed.test.ts @@ -0,0 +1,78 @@ +/* eslint-disable init-declarations */ +import { Binden, type Context } from "binden"; +import { type Server, request } from "node:http"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { Multipart } from "../index.js"; +import { deepEqual } from "node:assert/strict"; + +const port = 8080; +const url = `http://localhost:${port}/`; + +function post(body: string, boundary: string): Promise { + return new Promise((resolve, reject) => { + request(url, { + method: "POST", + headers: { + "Content-Type": `multipart/form-data; boundary=${boundary}`, + "Content-Length": Buffer.byteLength(body), + }, + }) + .once("error", reject) + .once("response", (response) => { + response.resume(); + resolve(response.statusCode ?? 0); + }) + .end(body); + }); +} + +describe("Multipart (malformed body)", () => { + let server: Server; + + beforeEach(async () => { + const app = new Binden().use(new Multipart(), (ct: Context) => + ct.send("OK"), + ); + await new Promise((resolve) => { + server = app.createServer().listen(port, resolve); + }); + }); + + it("Replies with 415 error when a file part is not terminated", async () => { + const boundary = "binden-boundary"; + const truncated = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="file"; filename="file.txt"', + "Content-Type: text/plain", + "", + "Hello World", + ].join("\r\n"); + + deepEqual(await post(truncated, boundary), 415); + + const complete = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="file"; filename="file.txt"', + "Content-Type: text/plain", + "", + "Hello World", + `--${boundary}--`, + "", + ].join("\r\n"); + + deepEqual(await post(complete, boundary), 200); + }); + + afterEach(async () => { + server.closeAllConnections(); + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + }); +}); From 8ac4aa92874b6257057c7394a5a5ef9d53ba761a Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 18:25:28 +0300 Subject: [PATCH 014/675] fix: reject truncated fields in @binden/multipart when throw_limits is enabled A field value cut off by the `fieldSize` limit was silently passed on, even though `throw_limits` (enabled by default) rejects every other limit with `415`. Truncated values are now rejected as well, and still passed on (truncated) with `throw_limits: false`. Co-Authored-By: Claude Opus 5.5 --- packages/multipart/index.ts | 12 ++++++-- packages/multipart/test/multipart.test.ts | 34 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/multipart/index.ts b/packages/multipart/index.ts index 824c83a9..6a66e798 100644 --- a/packages/multipart/index.ts +++ b/packages/multipart/index.ts @@ -64,8 +64,16 @@ export class Multipart extends Middleware { request.body = fd; resolve(); }) - .on("field", (name, value) => { - fd.append(name, value); + .on("field", (name, value, { valueTruncated }) => { + // `nameTruncated` is never set for `multipart/form-data` (busboy applies + // `fieldNameSize` to `application/x-www-form-urlencoded` only) + if (this.#throw_limits && valueTruncated) { + const cause = new Error("`busboy` reached `fieldSize` limit "); + const error = new BindenError(415, { cause }); + reject(error); + } else { + fd.append(name, value); + } }) .on("file", (name, stream, { mimeType: type, filename }) => { const chunks: Buffer[] = []; diff --git a/packages/multipart/test/multipart.test.ts b/packages/multipart/test/multipart.test.ts index 5a7c2e71..2a51d787 100644 --- a/packages/multipart/test/multipart.test.ts +++ b/packages/multipart/test/multipart.test.ts @@ -227,6 +227,40 @@ describe("Multipart", () => { deepEqual(response.status, 200); }); + it("Replies with 415 error when a field reaches the `fieldSize` limit (by default)", async () => { + const body = new FormData(); + body.append("k1", "abcdef"); + + app.use(multipart({ limits: { fieldSize: 3 } })); + + const response = await fetch(url, { method: "POST", body }); + await response.text(); + deepEqual(response.status, 415); + }); + + it("Passes a truncated field when it reaches the `fieldSize` limit (`throw_limits = false`)", async () => { + const body = new FormData(); + body.append("k1", "abcdef"); + + const assert = (context: Context): Promise => { + ok(context.request.body instanceof FormData); + deepEqual(context.request.body.getAll("k1"), ["abc"]); + return context.send(); + }; + + app.use( + new Multipart({ + config: { limits: { fieldSize: 3 } }, + throw_limits: false, + }), + assert, + ); + + const response = await fetch(url, { method: "POST", body }); + await response.text(); + deepEqual(response.status, 200); + }); + it("Replies with 415 error when FormData reaches the `files` limit (by default)", async () => { const body = new FormData(); body.append("file1", new File([Buffer.allocUnsafe(50)], "file1.txt")); From bf55b45852811ba4bda76032a6bbb67772c31162 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 18:26:11 +0300 Subject: [PATCH 015/675] docs: fix the custom parse example of @binden/body The default export is a ready `BodyParser` instance, so the example now imports the class, registers the parser it creates and type-checks with the repository settings. Co-Authored-By: Claude Opus 5.5 --- packages/body/README.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/body/README.md b/packages/body/README.md index d956ace0..8f0a321f 100644 --- a/packages/body/README.md +++ b/packages/body/README.md @@ -37,23 +37,25 @@ app.use("/deposit", new Router().post(BodyParser, new MyMiddleware())); ### Custom `parse` function ```typescript -import { Binden } from "binden"; -import AJV, { JTDDataType } from "ajv/dist/jtd.js"; -import BodyParser from "@binden/body"; -const ajv = new AJV(); +import { Binden, type Context, Router } from "binden"; +import { Ajv, type JTDDataType } from "ajv/dist/jtd.js"; +import { BodyParser } from "@binden/body"; + +const port = 8080; +const ajv = new Ajv(); const schema = { properties: { username: { type: "string" }, password: { type: "string" } }, additionalProperties: false, -}; +} as const; const parse = ajv.compileParser>(schema); const body_parser = new BodyParser({ parse }); -const echo = (context) => { - const { body } = context.request; +const echo = (context: Context): Promise => { + const body = context.request.body as JTDDataType; context.log.info("Body has been parsed (or not)", { body }); return context.json(body); }; new Binden() - .use("/login", new Router().post(BodyParser, echo)) + .use("/login", new Router().post(body_parser, echo)) .createServer() .listen(port, () => { console.log(`Server is listening on the port ${port}`); From 763ff4cda5e9ed923816238ee51625910c896203 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 18:27:21 +0300 Subject: [PATCH 016/675] test: forward options to the node:test runner Every workspace runs its tests through `scripts/test.mjs`, which places the extra arguments before the test files, so `npm test -w packages/body -- --test-name-pattern=JSON` (or `npm test -- --test-reporter=dot` for all workspaces) reaches the Node.js test runner instead of being treated as a test file pattern or an npm option. Co-Authored-By: Claude Opus 5.5 --- package.json | 4 ++-- packages/binden/package.json | 2 +- packages/body/package.json | 2 +- packages/compression/package.json | 2 +- packages/logger/package.json | 2 +- packages/multipart/package.json | 2 +- scripts/test.mjs | 32 +++++++++++++++++++++++++++++++ 7 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 scripts/test.mjs diff --git a/package.json b/package.json index 870948e9..adfec64d 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "build": "npm run build --workspaces", "ci:all": "npm ci && npm run release:check && npm run prettier && npm run commitlint && npm run eslint && npm run coverage && npm run pack:check && npm run docs:build", "commitlint": "commitlint --verbose --from=origin/main", - "coverage": "c8 npm test", + "coverage": "c8 npm test --", "docs:build": "typedoc", "eslint": "eslint .", "eslint:fix": "eslint . --fix", @@ -29,7 +29,7 @@ "prettier": "prettier -c .", "prettier:write": "prettier -c -w .", "release:check": "node scripts/release.mjs check", - "test": "npm test --workspaces --if-present" + "test": "npm test --workspaces --if-present --" }, "repository": { "type": "git", diff --git a/packages/binden/package.json b/packages/binden/package.json index 816e21ea..6aaacbd6 100644 --- a/packages/binden/package.json +++ b/packages/binden/package.json @@ -17,7 +17,7 @@ "build": "tsc", "prebuild": "rm -rf dist", "prepack": "npm run build", - "test": "node --import tsx --import ./test/setup.ts --test --test-concurrency=1 --test-timeout=60000 \"test/**/*.test.ts\"" + "test": "node ../../scripts/test.mjs" }, "repository": { "type": "git", diff --git a/packages/body/package.json b/packages/body/package.json index aa6ecf3b..68b7272d 100644 --- a/packages/body/package.json +++ b/packages/body/package.json @@ -17,7 +17,7 @@ "build": "tsc", "prebuild": "rm -rf dist", "prepack": "npm run build", - "test": "node --import tsx --import ./test/setup.ts --test --test-concurrency=1 --test-timeout=60000 \"test/**/*.test.ts\"" + "test": "node ../../scripts/test.mjs" }, "repository": { "type": "git", diff --git a/packages/compression/package.json b/packages/compression/package.json index 89862d08..6d5e3c86 100644 --- a/packages/compression/package.json +++ b/packages/compression/package.json @@ -17,7 +17,7 @@ "build": "tsc", "prebuild": "rm -rf dist", "prepack": "npm run build", - "test": "node --import tsx --import ./test/setup.ts --test --test-concurrency=1 --test-timeout=60000 \"test/**/*.test.ts\"" + "test": "node ../../scripts/test.mjs" }, "repository": { "type": "git", diff --git a/packages/logger/package.json b/packages/logger/package.json index 1dbbf09f..e6efa603 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -17,7 +17,7 @@ "build": "tsc", "prebuild": "rm -rf dist", "prepack": "npm run build", - "test": "node --import tsx --test --test-concurrency=1 --test-timeout=60000 \"test/**/*.test.ts\"" + "test": "node ../../scripts/test.mjs" }, "repository": { "type": "git", diff --git a/packages/multipart/package.json b/packages/multipart/package.json index 4dff8b34..2d4015f2 100644 --- a/packages/multipart/package.json +++ b/packages/multipart/package.json @@ -17,7 +17,7 @@ "build": "tsc", "prebuild": "rm -rf dist", "prepack": "npm run build", - "test": "node --import tsx --import ./test/setup.ts --test --test-concurrency=1 --test-timeout=60000 \"test/**/*.test.ts\"" + "test": "node ../../scripts/test.mjs" }, "repository": { "type": "git", diff --git a/scripts/test.mjs b/scripts/test.mjs new file mode 100644 index 00000000..9d811a0e --- /dev/null +++ b/scripts/test.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +// Runs the `node:test` suite of the current workspace (`npm test` in `packages/*`). +// Extra arguments are Node.js test runner options, placed before the test files: +// +// npm test -w packages/body -- --test-name-pattern="JSON" +// npm test -- --test-reporter=dot +import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; + +const setup = existsSync("test/setup.ts") + ? ["--import", "./test/setup.ts"] + : []; + +const { status, signal } = spawnSync( + process.execPath, + [ + "--import", + "tsx", + ...setup, + "--test", + "--test-concurrency=1", + "--test-timeout=60000", + ...process.argv.slice(2), + "test/**/*.test.ts", + ], + { stdio: "inherit" }, +); + +if (signal !== null) { + process.kill(process.pid, signal); +} +process.exitCode = status ?? 1; From 8a455a2f6f1b03518f873fa1341fe972c1e57650 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 18:27:24 +0300 Subject: [PATCH 017/675] chore: ignore .env files again The migration dropped the `.env` and `.env.test` entries of the former repositories, so local environment files (and their secrets) could be committed by accident. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 5eee8879..59783b49 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ docs/ # OS files .DS_Store + +# Environment files (may contain secrets) +.env +.env.* +!.env.example From b031c9a302c07c440bb8572c76a42ccbcca0c917 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 18:27:59 +0300 Subject: [PATCH 018/675] ci: quote the GPG passphrase in the release job The `gpg` wrapper used by git in the release job expanded the passphrase and its arguments unquoted, so a passphrase containing spaces was split into several arguments. The key, the key id, the passphrase and the forwarded arguments are quoted now. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05f1c031..511c1b7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,20 +123,21 @@ jobs: registry-url: https://registry.npmjs.org - name: Import GPG key - run: echo $GPG_KEY | base64 --decode | gpg --batch --import + run: printf "%s" "$GPG_KEY" | base64 --decode | gpg --batch --import env: GPG_KEY: ${{ secrets.GPG_KEY }} - name: Add the custom gpg executable + # The passphrase is read from the environment of the release step, quoted, so + # that it is passed as a single argument even if it contains spaces. run: | - rm -rf /tmp/gpg.sh - echo 'gpg --batch --pinentry-mode=loopback --passphrase $GPG_KEY_PASSPHRASE $@' >> /tmp/gpg.sh + printf "%s\n" "#!/bin/sh" 'exec gpg --batch --pinentry-mode=loopback --passphrase "$GPG_KEY_PASSPHRASE" "$@"' > /tmp/gpg.sh chmod +x /tmp/gpg.sh - name: Setup git run: | git config commit.gpgsign true - git config user.signingkey $GPG_KEY_ID + git config user.signingkey "$GPG_KEY_ID" git config gpg.program /tmp/gpg.sh env: GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }} From 0e5071013e9707f796c82948c36fcaec794aeffd Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 18:30:31 +0300 Subject: [PATCH 019/675] test: cover the release script and the test runner `scripts/release.test.mjs` runs `release.mjs` in a fixture repository with a fake `npm` (lockstep check, E404 vs registry errors in `verify`, `version`, publish order, refusing to publish out of lockstep and `--skip-existing`). `scripts/test.test.mjs` checks that `scripts/test.mjs` forwards test runner options, loads `test/setup.ts` and keeps the exit status. They run in CI (`npm run test:tooling`). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 + package.json | 5 +- scripts/release.test.mjs | 217 +++++++++++++++++++++++++++++++++++++++ scripts/test.test.mjs | 86 ++++++++++++++++ 4 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 scripts/release.test.mjs create mode 100644 scripts/test.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 511c1b7f..e29e6791 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,9 @@ jobs: - name: ESLint run: npm run eslint + - name: Tooling tests (release script, test runner) + run: npm run test:tooling + - name: Pack (test) run: npm run pack:check diff --git a/package.json b/package.json index adfec64d..b7da4800 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ }, "scripts": { "build": "npm run build --workspaces", - "ci:all": "npm ci && npm run release:check && npm run prettier && npm run commitlint && npm run eslint && npm run coverage && npm run pack:check && npm run docs:build", + "ci:all": "npm ci && npm run release:check && npm run prettier && npm run commitlint && npm run eslint && npm run test:tooling && npm run coverage && npm run pack:check && npm run docs:build", "commitlint": "commitlint --verbose --from=origin/main", "coverage": "c8 npm test --", "docs:build": "typedoc", @@ -29,7 +29,8 @@ "prettier": "prettier -c .", "prettier:write": "prettier -c -w .", "release:check": "node scripts/release.mjs check", - "test": "npm test --workspaces --if-present --" + "test": "npm test --workspaces --if-present --", + "test:tooling": "node --test scripts/*.test.mjs" }, "repository": { "type": "git", diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs new file mode 100644 index 00000000..d3211f4f --- /dev/null +++ b/scripts/release.test.mjs @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, it } from "node:test"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { deepEqual, equal, match } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; + +// A fake `npm` that logs its arguments and emulates `npm view` (E404 unless the +// package version is listed in FAKE_NPM_PUBLISHED, or a network error with +// FAKE_NPM_OFFLINE). Every other command succeeds without doing anything. +const fakeNpm = `#!/usr/bin/env node +const { appendFileSync } = require("node:fs"); +const args = process.argv.slice(2); +appendFileSync(process.env.FAKE_NPM_LOG, JSON.stringify(args) + "\\n"); +if (args[0] === "view") { + if (process.env.FAKE_NPM_OFFLINE) { + process.stderr.write("npm error code ECONNREFUSED\\n"); + process.exit(1); + } + const spec = args[1]; + const published = (process.env.FAKE_NPM_PUBLISHED ?? "").split(","); + if (published.includes(spec)) { + process.stdout.write(JSON.stringify(spec.slice(spec.lastIndexOf("@") + 1))); + } else { + process.stdout.write(JSON.stringify({ error: { code: "E404" } })); + process.exit(1); + } +} +`; + +const manifests = { + "packages/a": { name: "@fixture/a", version: "1.0.0" }, + "packages/b": { + name: "@fixture/b", + version: "1.0.0", + dependencies: { "@fixture/a": "^1.0.0" }, + }, + "packages/c": { + name: "@fixture/c", + version: "1.0.0", + peerDependencies: { "@fixture/b": "^1.0.0" }, + devDependencies: { "left-pad": "^1.3.0" }, + }, +}; + +describe("scripts/release.mjs", () => { + let root = ""; + + const writeJSON = (file, data) => { + writeFileSync(join(root, file), `${JSON.stringify(data, null, 2)}\n`); + }; + const readJSON = (file) => JSON.parse(readFileSync(join(root, file), "utf8")); + + const release = (args, env = {}) => + spawnSync(process.execPath, [join(root, "scripts/release.mjs"), ...args], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${join(root, "bin")}:${process.env.PATH ?? ""}`, + FAKE_NPM_LOG: join(root, "npm.log"), + ...env, + }, + }); + + const npmCalls = (command) => { + const log = join(root, "npm.log"); + return existsSync(log) + ? readFileSync(log, "utf8") + .split("\n") + .filter((line) => line !== "") + .map((line) => JSON.parse(line)) + .filter(([name]) => name === command) + : []; + }; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "binden-release-")); + mkdirSync(join(root, "scripts")); + mkdirSync(join(root, "bin")); + copyFileSync( + join(import.meta.dirname, "release.mjs"), + join(root, "scripts/release.mjs"), + ); + writeFileSync(join(root, "bin/npm"), fakeNpm); + chmodSync(join(root, "bin/npm"), 0o755); + writeJSON("package.json", { + private: true, + workspaces: Object.keys(manifests), + }); + for (const [dir, manifest] of Object.entries(manifests)) { + mkdirSync(join(root, dir), { recursive: true }); + writeJSON(`${dir}/package.json`, manifest); + } + }); + + afterEach(() => { + rmSync(root, { force: true, recursive: true }); + }); + + it("check passes when the workspaces are in lockstep", () => { + const { status, stderr } = release(["check"]); + equal(status, 0); + match(stderr, /3 workspaces in lockstep at 1\.0\.0/u); + }); + + it("check fails on a version drift", () => { + writeJSON("packages/c/package.json", { + ...manifests["packages/c"], + version: "1.0.1", + }); + const { status, stderr } = release(["check"]); + equal(status, 1); + match(stderr, /packages\/c: version 1\.0\.1 != 1\.0\.0/u); + }); + + it("check fails on an internal range drift", () => { + writeJSON("packages/b/package.json", { + ...manifests["packages/b"], + dependencies: { "@fixture/a": "^0.9.0" }, + }); + const { status, stderr } = release(["check"]); + equal(status, 1); + match( + stderr, + /dependencies\.@fixture\/a is \^0\.9\.0, expected \^1\.0\.0/u, + ); + }); + + it("verify passes when no workspace is published (E404)", () => { + const { status, stderr } = release(["verify", "2.0.0"]); + equal(status, 0); + match(stderr, /no workspace is published at 2\.0\.0 yet/u); + }); + + it("verify fails when a workspace is already published", () => { + const env = { FAKE_NPM_PUBLISHED: "@fixture/b@2.0.0" }; + const { status, stderr } = release(["verify", "2.0.0"], env); + equal(status, 1); + match(stderr, /Already published at 2\.0\.0: @fixture\/b/u); + }); + + it("verify fails on registry errors (not only on E404)", () => { + const env = { FAKE_NPM_OFFLINE: "1" }; + const { status, stderr } = release(["verify", "2.0.0"], env); + equal(status, 1); + match(stderr, /ECONNREFUSED/u); + }); + + it("verify and version reject an invalid version", () => { + for (const command of ["verify", "version"]) { + const { status, stderr } = release([command, "2.x"]); + equal(status, 1); + match(stderr, /Invalid version: 2\.x/u); + } + }); + + it("version updates every workspace and the lockfile", () => { + const { status } = release(["version", "2.0.0"]); + equal(status, 0); + deepEqual(readJSON("packages/a/package.json").version, "2.0.0"); + deepEqual(readJSON("packages/b/package.json").dependencies, { + "@fixture/a": "^2.0.0", + }); + const c = readJSON("packages/c/package.json"); + deepEqual(c.version, "2.0.0"); + deepEqual(c.peerDependencies, { "@fixture/b": "^2.0.0" }); + deepEqual(c.devDependencies, { "left-pad": "^1.3.0" }); + deepEqual(npmCalls("install"), [ + ["install", "--package-lock-only", "--ignore-scripts"], + ]); + }); + + it("publish publishes every workspace in the workspaces order", () => { + const { status } = release(["publish"]); + equal(status, 0); + deepEqual( + npmCalls("publish"), + Object.keys(manifests).map((dir) => ["publish", "--workspace", dir]), + ); + }); + + it("publish refuses to publish workspaces out of lockstep", () => { + writeJSON("packages/b/package.json", { + ...manifests["packages/b"], + version: "1.0.1", + }); + const { status, stderr } = release(["publish", "--skip-existing"]); + equal(status, 1); + match(stderr, /not in lockstep/u); + deepEqual(npmCalls("publish"), []); + }); + + it("publish --skip-existing skips published workspaces", () => { + const env = { FAKE_NPM_PUBLISHED: "@fixture/a@1.0.0" }; + const { status } = release(["publish", "--skip-existing"], env); + equal(status, 0); + deepEqual(npmCalls("publish"), [ + ["publish", "--workspace", "packages/b"], + ["publish", "--workspace", "packages/c"], + ]); + }); + + it("fails on an unknown command", () => { + const { status, stderr } = release(["deploy"]); + equal(status, 1); + match(stderr, /Unknown command: deploy/u); + }); +}); diff --git a/scripts/test.test.mjs b/scripts/test.test.mjs new file mode 100644 index 00000000..761cc40a --- /dev/null +++ b/scripts/test.test.mjs @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, it } from "node:test"; +import { equal, match, ok } from "node:assert/strict"; +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; + +const runner = join(import.meta.dirname, "test.mjs"); +const root = join(import.meta.dirname, ".."); + +// The nested test runner must not report to this one +const env = { ...process.env }; +delete env.NODE_TEST_CONTEXT; + +function run(cwd, args = []) { + return spawnSync(process.execPath, [runner, ...args], { + cwd, + encoding: "utf8", + env, + }); +} + +describe("scripts/test.mjs", () => { + it("forwards test runner options (before the test files)", () => { + const { status, stdout } = run(join(root, "packages/logger"), [ + "--test-name-pattern=ENV_VARIABLE_NAME", + ]); + equal(status, 0); + match(stdout, /✔ \.ENV_VARIABLE_NAME/u); + ok(!stdout.includes("serializes errors")); + }); + + it("forwards the reporter", () => { + const { status, stdout } = run(join(root, "packages/logger"), [ + "--test-reporter=tap", + "--test-name-pattern=ENV_VARIABLE_NAME", + ]); + equal(status, 0); + match(stdout, /^TAP version 13/u); + }); + + describe("with a fixture workspace", () => { + let fixture = ""; + + beforeEach(() => { + fixture = mkdtempSync(join(tmpdir(), "binden-test-runner-")); + symlinkSync(join(root, "node_modules"), join(fixture, "node_modules")); + mkdirSync(join(fixture, "test")); + writeFileSync( + join(fixture, "test/setup.ts"), + "(globalThis as Record).setup = true;\n", + ); + writeFileSync( + join(fixture, "test/fixture.test.ts"), + [ + 'import { ok } from "node:assert/strict";', + 'import { test } from "node:test";', + 'test("loads test/setup.ts", () => {', + " ok((globalThis as Record).setup);", + "});", + 'test("fails", () => {', + ' throw new Error("expected");', + "});", + "", + ].join("\n"), + ); + }); + + afterEach(() => { + rmSync(fixture, { force: true, recursive: true }); + }); + + it("loads test/setup.ts and exits with the status of the tests", () => { + const { status, stdout } = run(fixture); + equal(status, 1); + match(stdout, /✔ loads test\/setup\.ts/u); + match(stdout, /✖ fails/u); + }); + }); +}); From 02dbbc391428d848c5b6f19289d43bc5093b44a8 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 18:32:54 +0300 Subject: [PATCH 020/675] ci: check the packed packages as a consumer `scripts/consumer-check.mjs` installs the packed workspaces into a fresh project, type checks a consumer that uses every package and runs it as ESM against a real server. CI runs it with `@types/node` 24 (strict) and 26. The latter needs `skipLibCheck` because of an upstream issue in the types of `thread-stream` (used by `pino`), documented in the README. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 + README.md | 8 ++ package.json | 3 +- scripts/consumer-check.mjs | 156 +++++++++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 scripts/consumer-check.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e29e6791..97b8f7a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,9 @@ jobs: - name: Pack (test) run: npm run pack:check + - name: Consumer check (packed packages, TypeScript and ESM) + run: npm run consumer:check + - name: Docs run: npm run docs:build diff --git a/README.md b/README.md index 0afa630e..4c8339d5 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,14 @@ Packages consume each other through the compiled `dist/` (`@binden/body`, `@bind `@binden/multipart` import `binden`, which imports `@binden/logger`): run `npm run build` after changing a package that others depend on. +## Known issues + +- With `@types/node` 26 and `skipLibCheck: false`, TypeScript reports + `Namespace "worker_threads" has no exported member "TransferListItem"` in the types of + [`thread-stream`](https://github.com/pinojs/thread-stream), which `pino` (used by + `@binden/logger`) depends on. Enable `skipLibCheck` until `thread-stream` is updated. + `npm run consumer:check` type checks the packed packages with `@types/node` 24 (strict) and 26. + ## Releases Releases are fully automated by [semantic-release](https://github.com/semantic-release/semantic-release) diff --git a/package.json b/package.json index b7da4800..24267cad 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,9 @@ }, "scripts": { "build": "npm run build --workspaces", - "ci:all": "npm ci && npm run release:check && npm run prettier && npm run commitlint && npm run eslint && npm run test:tooling && npm run coverage && npm run pack:check && npm run docs:build", + "ci:all": "npm ci && npm run release:check && npm run prettier && npm run commitlint && npm run eslint && npm run test:tooling && npm run coverage && npm run pack:check && npm run consumer:check && npm run docs:build", "commitlint": "commitlint --verbose --from=origin/main", + "consumer:check": "node scripts/consumer-check.mjs", "coverage": "c8 npm test --", "docs:build": "typedoc", "eslint": "eslint .", diff --git a/scripts/consumer-check.mjs b/scripts/consumer-check.mjs new file mode 100644 index 00000000..a1c0bb5c --- /dev/null +++ b/scripts/consumer-check.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +// Checks the packed workspaces the way a consumer uses them: the tarballs are +// installed into a fresh project, which is type checked (with `@types/node` 24 +// and 26) and then run as ESM against a real server. +// +// `@types/node` 26 is checked with `skipLibCheck`: the types of `thread-stream` +// (pulled in by `pino`, used by `@binden/logger`) still refer to +// `worker_threads.TransferListItem`, which `@types/node` 26 removed. +import { + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const root = join(import.meta.dirname, ".."); +const { devDependencies } = JSON.parse( + readFileSync(join(root, "package.json"), "utf8"), +); + +const consumer = `import { once } from "node:events"; +import type { AddressInfo } from "node:net"; +import { Binden, BindenError, type Context, Router } from "binden"; +import log, { Logger } from "@binden/logger"; +import bodyParser, { BodyParser } from "@binden/body"; +import compression, { Compression } from "@binden/compression"; +import multipart, { Multipart } from "@binden/multipart"; + +const logger = new Logger({ level: "silent" }).child({ consumer: true }); +logger.info("consumer check"); +log.debug("default logger"); + +const parse = (input: string): { message: string } => + JSON.parse(input) as { message: string }; + +const app = new Binden() + .use(new Compression({ format: "auto" })) + .use( + "/json", + new Router().post(new BodyParser({ parse }), (context: Context) => + context.json({ received: context.request.body as Record }), + ), + ) + .use( + "/form", + new Router().post( + new Multipart({ config: { limits: { fields: 4 } } }), + (context: Context) => + context.text(String((context.request.body as FormData).get("field"))), + ), + ) + .use("/error", () => { + throw new BindenError(400, { expose: true, message: "consumer error" }); + }); + +if (![bodyParser, compression].every(Boolean) || typeof multipart !== "function") { + throw new Error("Missing default exports"); +} + +const server = app.createServer().listen(0); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; +const url = \`http://localhost:\${port}\`; + +try { + const json = await fetch(\`\${url}/json\`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hello" }), + }); + const { received } = (await json.json()) as { received: { message: string } }; + if (received.message !== "hello") { + throw new Error("Unexpected JSON response"); + } + + const form = new FormData(); + form.append("field", "value"); + const text = await (await fetch(\`\${url}/form\`, { method: "POST", body: form })).text(); + if (text !== "value") { + throw new Error("Unexpected FormData response"); + } + + const error = await fetch(\`\${url}/error\`); + if (error.status !== 400 || (await error.text()) !== "consumer error") { + throw new Error("Unexpected error response"); + } +} finally { + server.close(); +} +`; + +const dir = mkdtempSync(join(tmpdir(), "binden-consumer-")); + +function run(command, args, cwd = dir) { + process.stderr.write(`[consumer] ${command} ${args.join(" ")}\n`); + execFileSync(command, args, { cwd, stdio: ["ignore", "inherit", "inherit"] }); +} + +try { + run( + "npm", + ["pack", "--workspaces", "--pack-destination", dir, "--loglevel=warn"], + root, + ); + const tarballs = readdirSync(dir) + .filter((file) => file.endsWith(".tgz")) + .map((file) => join(dir, file)); + + writeFileSync( + join(dir, "package.json"), + `${JSON.stringify({ private: true, type: "module" })}\n`, + ); + writeFileSync(join(dir, "consumer.ts"), consumer); + + for (const [types, skipLibCheck] of [ + ["24", false], + ["26", true], + ]) { + run("npm", [ + "install", + "--no-audit", + "--no-fund", + "--loglevel=warn", + ...tarballs, + `typescript@${devDependencies.typescript}`, + `@types/node@${types}`, + ]); + writeFileSync( + join(dir, "tsconfig.json"), + `${JSON.stringify({ + compilerOptions: { + module: "nodenext", + target: "es2022", + strict: true, + skipLibCheck, + types: ["node"], + outDir: "dist", + }, + files: ["consumer.ts"], + })}\n`, + ); + run(process.execPath, [ + join(dir, "node_modules/typescript/bin/tsc"), + "-p", + dir, + ]); + run(process.execPath, [join(dir, "dist/consumer.js")]); + } + process.stderr.write("[consumer] OK\n"); +} finally { + rmSync(dir, { force: true, recursive: true }); +} From 2abd3ec79b015667971d237585ed2a82c3a6b438 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 18:34:24 +0300 Subject: [PATCH 021/675] docs: build the API docs without warnings Map the `@types/node` symbols referenced by the inherited `IncomingMessage` comments to the Node.js documentation, declare the internal `IMiddlewareReturnType` of `binden` and highlight `http` code blocks, so TypeDoc runs with `treatWarningsAsErrors`. The license and code of conduct badges link to GitHub, since the relative links are broken on the documentation site. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- packages/binden/README.md | 2 +- packages/binden/typedoc.json | 3 +++ packages/body/README.md | 2 +- packages/compression/README.md | 2 +- packages/logger/README.md | 2 +- packages/multipart/README.md | 2 +- typedoc.json | 27 +++++++++++++++++++++++++-- 8 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 packages/binden/typedoc.json diff --git a/README.md b/README.md index 4c8339d5..e721ad19 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# binden [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) +# binden [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/binden-js/binden/blob/main/CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) A simple HTTP server framework and its official packages. All packages live in this repository as [npm workspaces](https://docs.npmjs.com/cli/using-npm/workspaces) and are released together with diff --git a/packages/binden/README.md b/packages/binden/README.md index 0716901e..fabb2b4d 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -1,4 +1,4 @@ -# Binden [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![version](https://img.shields.io/npm/v/binden?style=plastic)](https://www.npmjs.com/package/binden) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/binden-js/binden/blob/main/CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![node version](https://img.shields.io/node/v/binden) ![npm downloads](https://img.shields.io/npm/dt/binden) [![License](https://img.shields.io/npm/l/binden)](LICENSE) +# Binden [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![version](https://img.shields.io/npm/v/binden?style=plastic)](https://www.npmjs.com/package/binden) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/binden-js/binden/blob/main/CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![node version](https://img.shields.io/node/v/binden) ![npm downloads](https://img.shields.io/npm/dt/binden) [![License](https://img.shields.io/npm/l/binden)](https://github.com/binden-js/binden/blob/main/packages/binden/LICENSE) A simple server framework (written in [TypeScript](https://www.typescriptlang.org/)). diff --git a/packages/binden/typedoc.json b/packages/binden/typedoc.json new file mode 100644 index 00000000..66f45922 --- /dev/null +++ b/packages/binden/typedoc.json @@ -0,0 +1,3 @@ +{ + "intentionallyNotExported": ["IMiddlewareReturnType"] +} diff --git a/packages/body/README.md b/packages/body/README.md index 8f0a321f..b785f291 100644 --- a/packages/body/README.md +++ b/packages/body/README.md @@ -1,4 +1,4 @@ -# @binden/body [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![version](https://img.shields.io/npm/v/@binden/body?style=plastic)](https://www.npmjs.com/package/@binden/body) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/binden-js/binden/blob/main/CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![node version](https://img.shields.io/node/v/@binden/body) ![npm downloads](https://img.shields.io/npm/dt/@binden/body) [![License](https://img.shields.io/npm/l/@binden/body)](LICENSE) +# @binden/body [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![version](https://img.shields.io/npm/v/@binden/body?style=plastic)](https://www.npmjs.com/package/@binden/body) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/binden-js/binden/blob/main/CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![node version](https://img.shields.io/node/v/@binden/body) ![npm downloads](https://img.shields.io/npm/dt/@binden/body) [![License](https://img.shields.io/npm/l/@binden/body)](https://github.com/binden-js/binden/blob/main/packages/body/LICENSE) [Binden](https://github.com/binden-js/binden) body parser middleware. Supports the following [content types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) diff --git a/packages/compression/README.md b/packages/compression/README.md index b936186c..40c40777 100644 --- a/packages/compression/README.md +++ b/packages/compression/README.md @@ -1,4 +1,4 @@ -# @binden/compression [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![version](https://img.shields.io/npm/v/@binden/compression?style=plastic)](https://www.npmjs.com/package/@binden/compression) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/binden-js/binden/blob/main/CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![node version](https://img.shields.io/node/v/@binden/compression) ![npm downloads](https://img.shields.io/npm/dt/@binden/compression) [![License](https://img.shields.io/npm/l/@binden/compression)](LICENSE) +# @binden/compression [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![version](https://img.shields.io/npm/v/@binden/compression?style=plastic)](https://www.npmjs.com/package/@binden/compression) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/binden-js/binden/blob/main/CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![node version](https://img.shields.io/node/v/@binden/compression) ![npm downloads](https://img.shields.io/npm/dt/@binden/compression) [![License](https://img.shields.io/npm/l/@binden/compression)](https://github.com/binden-js/binden/blob/main/packages/compression/LICENSE) [Binden](https://github.com/binden-js/binden) compression middleware (supports `br`, `gzip` and `deflate`). diff --git a/packages/logger/README.md b/packages/logger/README.md index 1dd6bc7c..47e5403c 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -1,4 +1,4 @@ -# @binden/logger [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![version](https://img.shields.io/npm/v/@binden/logger?style=plastic)](https://www.npmjs.com/package/@binden/logger) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/binden-js/binden/blob/main/CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![node version](https://img.shields.io/node/v/@binden/logger) ![npm downloads](https://img.shields.io/npm/dt/@binden/logger) [![License](https://img.shields.io/npm/l/@binden/logger)](LICENSE) +# @binden/logger [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![version](https://img.shields.io/npm/v/@binden/logger?style=plastic)](https://www.npmjs.com/package/@binden/logger) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/binden-js/binden/blob/main/CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![node version](https://img.shields.io/node/v/@binden/logger) ![npm downloads](https://img.shields.io/npm/dt/@binden/logger) [![License](https://img.shields.io/npm/l/@binden/logger)](https://github.com/binden-js/binden/blob/main/packages/logger/LICENSE) A simple logger (based on [`pino`](https://github.com/pinojs/pino)). diff --git a/packages/multipart/README.md b/packages/multipart/README.md index d01f7911..64ab19f7 100644 --- a/packages/multipart/README.md +++ b/packages/multipart/README.md @@ -1,4 +1,4 @@ -# @binden/multipart [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![version](https://img.shields.io/npm/v/@binden/multipart?style=plastic)](https://www.npmjs.com/package/@binden/multipart) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/binden-js/binden/blob/main/CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![node version](https://img.shields.io/node/v/@binden/multipart) ![npm downloads](https://img.shields.io/npm/dt/@binden/multipart) [![License](https://img.shields.io/npm/l/@binden/multipart)](LICENSE) +# @binden/multipart [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![version](https://img.shields.io/npm/v/@binden/multipart?style=plastic)](https://www.npmjs.com/package/@binden/multipart) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/binden-js/binden/blob/main/CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![node version](https://img.shields.io/node/v/@binden/multipart) ![npm downloads](https://img.shields.io/npm/dt/@binden/multipart) [![License](https://img.shields.io/npm/l/@binden/multipart)](https://github.com/binden-js/binden/blob/main/packages/multipart/LICENSE) [Binden](https://github.com/binden-js/binden) `multipart/form-data` parser middleware (based on [busboy](https://github.com/mscdex/busboy)). diff --git a/typedoc.json b/typedoc.json index f2913bc6..3c4f661d 100644 --- a/typedoc.json +++ b/typedoc.json @@ -4,8 +4,31 @@ "entryPointStrategy": "packages", "entryPoints": ["packages/*"], "packageOptions": { - "entryPoints": ["index.ts"] + "entryPoints": ["index.ts"], + "externalSymbolLinkMappings": { + "@types/node": { + "\"http\".request": "https://nodejs.org/api/http.html#httprequestoptions-callback", + "\"http\".createServer": "https://nodejs.org/api/http.html#httpcreateserveroptions-requestlistener", + "\"http\".Server": "https://nodejs.org/api/http.html#class-httpserver", + "\"http\".ClientRequest": "https://nodejs.org/api/http.html#class-httpclientrequest" + } + } }, "hideGenerator": true, - "gitRevision": "main" + "gitRevision": "main", + "highlightLanguages": [ + "bash", + "console", + "css", + "html", + "http", + "javascript", + "json", + "jsonc", + "json5", + "tsx", + "typescript", + "yaml" + ], + "treatWarningsAsErrors": true } From 74a9e669111a9fe0ca46b436863652f9b146adf1 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 18:34:26 +0300 Subject: [PATCH 022/675] chore: remove the install:clean scripts `install:clean` deleted `package-lock.json` before reinstalling, which silently upgrades every dependency; `npm ci` is the reproducible way to reinstall. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/package.json b/package.json index 24267cad..21461d27 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,7 @@ "docs:build": "typedoc", "eslint": "eslint .", "eslint:fix": "eslint . --fix", - "install:clean": "npm install", "pack:check": "npm pack --dry-run --workspaces", - "preinstall:clean": "rm -rf node_modules packages/*/node_modules package-lock.json", "prepare": "husky && npm run build", "prettier": "prettier -c .", "prettier:write": "prettier -c -w .", From 2fdf4ba809fc641ab197807abc391d7a40cd9651 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 18:41:07 +0300 Subject: [PATCH 023/675] docs: link the upstream thread-stream issue Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e721ad19..b5e62457 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ changing a package that others depend on. - With `@types/node` 26 and `skipLibCheck: false`, TypeScript reports `Namespace "worker_threads" has no exported member "TransferListItem"` in the types of [`thread-stream`](https://github.com/pinojs/thread-stream), which `pino` (used by - `@binden/logger`) depends on. Enable `skipLibCheck` until `thread-stream` is updated. + `@binden/logger`) depends on. Enable `skipLibCheck` until `thread-stream` is updated + ([pinojs/thread-stream#228](https://github.com/pinojs/thread-stream/issues/228)). `npm run consumer:check` type checks the packed packages with `@types/node` 24 (strict) and 26. ## Releases From 8886599dce11c9559c4882bf84d6998985e3d14e Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 19:04:42 +0300 Subject: [PATCH 024/675] fix: keep credentials out of the binden logs With `BINDEN_LOG_LEVEL=debug`, the framework logs (e.g. of every 404) serialized the request with the default pino serializer: the full URL with its query string, the parsed query and every header, including `Authorization` and `Cookie`. The request is now logged with the path only and credential headers (`Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie`, also in the response) are redacted. The serializers are no longer excluded from coverage. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/logger.ts | 33 +++++++- packages/binden/test/logger.test.ts | 116 ++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 packages/binden/test/logger.test.ts diff --git a/packages/binden/src/logger.ts b/packages/binden/src/logger.ts index 52fabda5..e245b457 100644 --- a/packages/binden/src/logger.ts +++ b/packages/binden/src/logger.ts @@ -2,7 +2,26 @@ import Pino from "pino"; import { Logger } from "@binden/logger"; import { BindenError } from "./error.js"; import { Context } from "./context.js"; -/* c8 ignore start */ + +const redacted = "[Redacted]"; + +/** Headers that carry credentials and must never be logged */ +const sensitive: ReadonlySet = new Set([ + "authorization", + "cookie", + "proxy-authorization", + "set-cookie", +]); + +function redact(headers: Record): Record { + return Object.fromEntries( + Object.entries(headers).map(([name, value]) => [ + name, + sensitive.has(name.toLowerCase()) ? redacted : value, + ]), + ); +} + export const serializers = { error: Pino.stdSerializers.wrapErrorSerializer((error) => { if (error.raw instanceof BindenError) { @@ -11,11 +30,18 @@ export const serializers = { } return error; }), + // The query string (and the parsed `query`/`params`) may contain tokens request: Pino.stdSerializers.wrapRequestSerializer( /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - ({ id: _id, ...rest }) => rest, + ({ id: _id, query: _query, params: _params, url, headers, ...rest }) => ({ + ...rest, + url: url.split("?", 1)[0], + headers: redact(headers), + }), + ), + response: Pino.stdSerializers.wrapResponseSerializer( + ({ headers, ...rest }) => ({ ...rest, headers: redact(headers) }), ), - response: Pino.stdSerializers.res, context: (input: unknown): unknown => { if (input instanceof Context) { return { @@ -27,6 +53,5 @@ export const serializers = { return input; }, }; -/* c8 ignore stop */ export default new Logger({ serializers }); diff --git a/packages/binden/test/logger.test.ts b/packages/binden/test/logger.test.ts new file mode 100644 index 00000000..d9fb2a5c --- /dev/null +++ b/packages/binden/test/logger.test.ts @@ -0,0 +1,116 @@ +import { deepStrictEqual, ok } from "node:assert/strict"; +import { Socket } from "node:net"; +import { suite, test } from "node:test"; +import { Logger } from "@binden/logger"; + +import { + BindenError, + BindenRequest, + BindenResponse, + Context, +} from "../index.js"; +import { serializers } from "../src/logger.js"; + +const secrets = [ + "secret-token", + "secret-cookie", + "secret-query", + "secret-set-cookie", +]; + +function createContext(): Context { + const request = new BindenRequest(new Socket()); + request.method = "GET"; + request.url = "/path?token=secret-query"; + request.headers = { + host: "localhost", + authorization: "Bearer secret-token", + "proxy-authorization": "Basic secret-token", + cookie: "session=secret-cookie", + }; + const response = new BindenResponse(request); + response.setHeader("Set-Cookie", "session=secret-set-cookie"); + response.setHeader("Content-Type", "text/plain"); + return new Context({ request, response }); +} + +function assertNoSecrets(output: string): void { + for (const secret of secrets) { + ok(!output.includes(secret), `${secret} leaked: ${output}`); + } +} + +suite("serializers", () => { + test("request: no query and redacted credentials", () => { + const { request } = createContext(); + const serialized = serializers.request(request) as { + method: string; + url: string; + headers: Record; + }; + const { method, url, headers } = serialized; + + deepStrictEqual({ method, url }, { method: "GET", url: "/path" }); + ok(!("query" in serialized)); + ok(!("params" in serialized)); + deepStrictEqual(headers, { + host: "localhost", + authorization: "[Redacted]", + "proxy-authorization": "[Redacted]", + cookie: "[Redacted]", + }); + }); + + test("response: redacted cookies", () => { + const { response } = createContext(); + const { headers } = serializers.response(response) as { + headers: Record; + }; + + deepStrictEqual(headers, { + "set-cookie": "[Redacted]", + "content-type": "text/plain", + }); + }); + + test("context", () => { + const context = createContext(); + const serialized = serializers.context(context); + + assertNoSecrets(JSON.stringify(serialized)); + deepStrictEqual(serializers.context("not a context"), "not a context"); + }); + + test("error", () => { + const error = serializers.error( + new BindenError(400, { expose: true, json: { message: "Bad" } }), + ); + deepStrictEqual( + [error.status, error.expose, error.json], + [400, true, { message: "Bad" }], + ); + + const plain = serializers.error(new Error("plain")); + deepStrictEqual(plain.message, "plain"); + ok(!("status" in plain)); + }); + + test("framework logs do not leak credentials", () => { + const lines: string[] = []; + const destination = { + write: (line: string): void => { + lines.push(line); + }, + }; + const log = new Logger({ serializers, destination, level: "debug" }); + + log.debug("Not found", { + context: createContext(), + error: new BindenError(404), + }); + + deepStrictEqual(lines.length, 1); + assertNoSecrets(lines.join("")); + ok(lines.join("").includes("[Redacted]")); + }); +}); From 9ab2a052a368eccf61b35f971577bee736c7ba85 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 19:05:38 +0300 Subject: [PATCH 025/675] fix: parse Accept-Encoding codings case-insensitively in binden `AcceptEncoding.fromString()` compared content codings case-sensitively and split parameters on the literal `;q=`, so `GZIP`, `gzip; q=0.5` or `gzip;Q=0.5` were dropped or lost their quality. Codings are lowercased and the `q` parameter is read case-insensitively, with optional whitespace. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/headers/accept-encoding.ts | 15 ++++++++++----- .../binden/test/headers/accept-encoding.test.ts | 11 +++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/binden/src/headers/accept-encoding.ts b/packages/binden/src/headers/accept-encoding.ts index 58ce411f..b9e7d01d 100644 --- a/packages/binden/src/headers/accept-encoding.ts +++ b/packages/binden/src/headers/accept-encoding.ts @@ -47,13 +47,18 @@ export class AcceptEncoding implements IAcceptEncoding { "identity", ]; + // Content codings and the `q` parameter are case-insensitive (RFC 9110) return input .split(",") - .map((e) => e.trim().split(";q=")) - .map<[string, number]>(([e, q]) => [ - e.trim(), - Number((q as string | undefined)?.trim()), - ]) + .map<[string, number]>((part) => { + const [coding = "", ...params] = part + .split(";") + .map((param) => param.trim()); + const q = params + .map((param) => /^q\s*=\s*(?.*)$/iu.exec(param)?.groups?.value) + .find((value) => typeof value === "string"); + return [coding.toLowerCase(), Number(q?.trim())]; + }) .filter((e): e is [IAcceptEncodings, number] => encodings.includes(e[0])) .map(([encoding, q_value]) => { if (isNaN(q_value)) { diff --git a/packages/binden/test/headers/accept-encoding.test.ts b/packages/binden/test/headers/accept-encoding.test.ts index fdf7835d..50c79aad 100644 --- a/packages/binden/test/headers/accept-encoding.test.ts +++ b/packages/binden/test/headers/accept-encoding.test.ts @@ -61,4 +61,15 @@ suite("AcceptEncoding", () => { deepStrictEqual(parsed[6].q_value, 0.1); deepStrictEqual(parsed[6].toString(), "identity;q=0.1"); }); + + test("AcceptEncoding.fromString() (case and whitespace insensitive)", () => { + const parsed = AcceptEncoding.fromString( + "GZIP;Q=0.5, Br ; q = 0.4, deflate; q=1, X-Gzip", + ); + + deepStrictEqual( + parsed.map((e) => e.toString()), + ["deflate;q=1", "x-gzip", "gzip;q=0.5", "br;q=0.4"], + ); + }); }); From e22a4e73266e5868cc9783868ffe5191018a2397 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 19:06:52 +0300 Subject: [PATCH 026/675] fix: choose the content coding by its effective quality in @binden/compression The coding was taken from the first acceptable `Accept-Encoding` entry, so `br;q=0.1, *;q=1` selected `br` although `gzip` and `deflate` had a higher quality through `*`. Every supported coding now gets its effective quality (its own `q`, otherwise the one of `*`) and the best one wins; `identity` wins only when it is explicitly preferred. When no coding is acceptable, the response is not compressed (RFC 9110 allows disregarding the header); the behaviour is documented in the README. Co-Authored-By: Claude Opus 5.5 --- packages/compression/README.md | 11 ++++ packages/compression/index.ts | 63 +++++++++---------- packages/compression/test/compression.test.ts | 56 +++++++++++++++++ 3 files changed, 98 insertions(+), 32 deletions(-) diff --git a/packages/compression/README.md b/packages/compression/README.md index 40c40777..a8e05a28 100644 --- a/packages/compression/README.md +++ b/packages/compression/README.md @@ -15,3 +15,14 @@ import Compression from "@binden/compression"; app.use(Compression); ``` + +### Content negotiation + +With the default `format: "auto"`, the content coding is chosen from the `Accept-Encoding` request header ([RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.3)): + +- without the header, `br` is used; +- otherwise, the coding (`br`, `gzip`, `deflate`) with the highest effective quality wins: its own `q` value, or the one of `*`, while `q=0` refuses a coding. Ties are resolved in the order `br`, `gzip`, `deflate`; +- the response is not compressed when `identity` is explicitly preferred, or when no supported coding is acceptable (the header is then disregarded instead of replying with `406 Not Acceptable`); +- `Vary: Accept-Encoding` is added to the response. + +A fixed `format` (`br`, `gzip` or `deflate`) compresses every response regardless of the request. diff --git a/packages/compression/index.ts b/packages/compression/index.ts index faccf4bc..9c2ac3b8 100644 --- a/packages/compression/index.ts +++ b/packages/compression/index.ts @@ -121,45 +121,44 @@ export class Compression extends Middleware { const { request } = context; - // No `Accept-Encoding` means that any coding is acceptable, while an empty - // one (or one refusing everything with `q=0`) asks for no coding at all. + // No `Accept-Encoding` means that any coding is acceptable if (typeof request.headers["accept-encoding"] === "undefined") { return DefaultCompression; } - const { accept_encoding } = request; - const refused = new Set( - accept_encoding - .filter(({ q_value }) => q_value === 0) - .map(({ encoding }) => encoding), - ); - - for (const { encoding } of accept_encoding.filter( - ({ q_value }) => q_value !== 0, - )) { - /* eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check */ - switch (encoding) { - case "*": { - const any = Compression.#formats.find((f) => !refused.has(f)); - if (typeof any !== "undefined") { - return any; - } - break; - } - case "identity": - return "identity"; - case "x-gzip": - return "gzip"; - case "br": - case "deflate": - case "gzip": - return encoding; - default: - break; + // The effective quality of a coding is its own `q` (`x-gzip` is `gzip`), + // otherwise the one of `*`, otherwise 0 (not acceptable) + const qualities = new Map(); + for (const { encoding, q_value } of request.accept_encoding) { + qualities.set(encoding === "x-gzip" ? "gzip" : encoding, q_value ?? 1); + } + const wildcard = qualities.get("*") ?? 0; + const quality = (coding: string): number => + qualities.get(coding) ?? wildcard; + + // The best acceptable coding (on a tie, in the order of `#formats`) + let best: Exclude | null = null; + for (const coding of Compression.#formats) { + if ( + quality(coding) > 0 && + (best === null || quality(coding) > quality(best)) + ) { + best = coding; } } - return "identity"; + // `identity` wins only when it is explicitly preferred. When no coding is + // acceptable, the header is disregarded and the response is not encoded + // (RFC 9110, section 12.5.3) instead of replying with 406. + const identity = qualities.get("identity"); + if ( + best === null || + (typeof identity === "number" && identity > quality(best)) + ) { + return "identity"; + } + + return best; } /** Response codings in the order of preference (the first one is the default) */ diff --git a/packages/compression/test/compression.test.ts b/packages/compression/test/compression.test.ts index cb0a2e37..e2feff83 100644 --- a/packages/compression/test/compression.test.ts +++ b/packages/compression/test/compression.test.ts @@ -292,6 +292,62 @@ describe("Compression", () => { deepStrictEqual(response.headers["content-encoding"], "gzip"); }); + it("auto (an explicit quality takes precedence over `*`)", async () => { + const expected = "Hello World"; + app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); + + const headers = { "Accept-Encoding": "br;q=0.1, *;q=1" }; + const response = await request(url, { headers }); + + deepStrictEqual(response.headers["content-encoding"], "gzip"); + + const body = await response.body.arrayBuffer(); + const actual = (await gunzip(Buffer.from(body))).toString(); + + deepStrictEqual(actual, expected); + }); + + it("auto (case-insensitive codings)", async () => { + const expected = "Hello World"; + app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); + + const headers = { "Accept-Encoding": "GZIP;q=1, identity;q=0" }; + const response = await request(url, { headers }); + + deepStrictEqual(response.headers["content-encoding"], "gzip"); + + const body = await response.body.arrayBuffer(); + const actual = (await gunzip(Buffer.from(body))).toString(); + + deepStrictEqual(actual, expected); + }); + + it("auto (a single coding with a low quality)", async () => { + const expected = "Hello World"; + app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); + + const headers = { "Accept-Encoding": "gzip;q=0.5" }; + const response = await request(url, { headers }); + + deepStrictEqual(response.headers["content-encoding"], "gzip"); + + const body = await response.body.arrayBuffer(); + const actual = (await gunzip(Buffer.from(body))).toString(); + + deepStrictEqual(actual, expected); + }); + + it("auto (no acceptable coding falls back to no compression)", async () => { + const expected = "Hello World"; + app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); + + const headers = { "Accept-Encoding": "identity;q=0, *;q=0" }; + const response = await request(url, { headers }); + + ok(typeof response.headers["content-encoding"] === "undefined"); + deepStrictEqual(await response.body.text(), expected); + }); + it("auto (unsupported)", async () => { const expected = "Hello World"; const format = "compress"; From e45cb704a3c12d8fce81a3d44c1d0b1f71640f28 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 19:15:04 +0300 Subject: [PATCH 027/675] fix: compress every write of the response and keep backpressure in @binden/compression The compressing proxy only covered writers that used the proxied context: the error handler of binden wrote to the response directly, so an error thrown after a first `write()` was sent as plain text under `Content-Encoding: gzip`. The proxy also broke the `write()` contract (it returned `undefined`) and its `drain` events (they came from the response, not the compressor). `write`/`end` are now replaced on the response itself, so every writer goes through the same compressor (several compressions chain), `write()` returns the result of the compressor and its `drain` is emitted as the `drain` of the response, while the compressor pauses as long as the response is full. As a consequence, error responses written after the middleware are compressed consistently instead of being sent uncompressed. Co-Authored-By: Claude Opus 5.5 --- packages/compression/index.ts | 128 ++++++++++-------- packages/compression/test/compression.test.ts | 95 ++++++++++++- 2 files changed, 163 insertions(+), 60 deletions(-) diff --git a/packages/compression/index.ts b/packages/compression/index.ts index 9c2ac3b8..f0135f69 100644 --- a/packages/compression/index.ts +++ b/packages/compression/index.ts @@ -1,5 +1,6 @@ import { Context, type IMiddlewareParams, Middleware } from "binden"; import { createBrotliCompress, createDeflate, createGzip } from "node:zlib"; +import { Writable } from "node:stream"; export const DefaultCompression = "br"; @@ -9,6 +10,18 @@ export interface ICompressionOptions extends IMiddlewareParams { format?: IComressFormats; } +type IWrite = ( + chunk: unknown, + encoding?: BufferEncoding | ((error?: Error | null) => void), + cb?: (error?: Error | null) => void, +) => boolean; + +type IEnd = ( + chunk?: unknown, + encoding?: BufferEncoding, + cb?: () => void, +) => void; + export class Compression extends Middleware { readonly #format: IComressFormats; @@ -21,19 +34,19 @@ export class Compression extends Middleware { return this.#format; } - public run(context: Context): Context { + public run(context: Context): void { + const { response } = context; + if (this.format === "auto") { - Compression.#varyOnAcceptEncoding(context); + Compression.#varyOnAcceptEncoding(response); } const format = Compression.#getFormat(context, this.format); - if (format === "identity") { - return context; + if (format === "identity" || response.headersSent) { + return; } - const { response } = context; - const compress = format === "br" ? createBrotliCompress() @@ -41,9 +54,15 @@ export class Compression extends Middleware { ? createDeflate() : createGzip(); - // Compression starts with the first body chunk written through this middleware, - // so responses written past it (e.g. by the error handler) stay unencoded, and - // a `Content-Length` of the uncompressed body (e.g. from `sendFile`) is dropped. + // `write`/`end` are replaced on the response itself, so that every writer + // (the following middlewares, `sendFile`, the error handler) goes through + // the compressor; several compressions chain their replaced methods. + const write = response.write.bind(response) as IWrite; + const end = response.end.bind(response) as IEnd; + + // Compression starts with the first body chunk. A `Content-Length` of the + // uncompressed body (e.g. from `sendFile`) is dropped, and nothing is + // compressed when the headers have already been sent. let active: boolean | null = null; const activate = (): boolean => { if (active === null) { @@ -57,58 +76,53 @@ export class Compression extends Middleware { : [...(Array.isArray(raw) ? raw : [`${raw}`]), format], ); response.removeHeader("Content-Length"); - compress.pipe(response); + // Backpressure in both directions: a compressed chunk is acknowledged + // once the response has flushed it (so `pipe` pauses the compressor + // while the response is full), and `drain` of the compressor is the + // `drain` that writers of the response wait for. + compress + .on("drain", () => response.emit("drain")) + .pipe( + new Writable({ + write(chunk, _encoding, callback): void { + write(chunk, () => { + callback(); + }); + }, + final(callback): void { + end(); + callback(); + }, + }), + ); } } return active; }; - const proxyResponse = new Proxy(response, { - get(_r, p: string): unknown { - if (p === "write") { - return function write( - data: unknown, - encoding: BufferEncoding, - cb?: (error?: Error | null) => void, - ): void { - if (activate()) { - compress.write(data, encoding, cb); - } else { - response.write(data, encoding, cb); - } - }; - } else if (p === "end") { - return function end( - data: unknown, - encoding: BufferEncoding, - cb?: () => void, - ): void { - if (activate()) { - compress.end(data, encoding, cb); - } else { - response.end(data, encoding, cb); - } - }; - } - - return (response as unknown as Record)[p]; - }, - }); - - return new Proxy(context, { - get(_ct, p: string): unknown { - if (p === "response") { - return proxyResponse; - } - - return (context as unknown as Record)[p]; - }, - - set(_ct, p: string, value: unknown): boolean { - (context as unknown as Record)[p] = value; - return true; - }, - }); + response.write = (( + chunk: unknown, + encoding?: BufferEncoding, + cb?: (error?: Error | null) => void, + ): boolean => { + if (activate()) { + return compress.write(chunk, encoding as BufferEncoding, cb); + } + return write(chunk, encoding, cb); + }) as typeof response.write; + + response.end = (( + chunk?: unknown, + encoding?: BufferEncoding, + cb?: () => void, + ): typeof response => { + if (activate()) { + compress.end(chunk, encoding as BufferEncoding, cb); + } else { + end(chunk, encoding, cb); + } + return response; + }) as typeof response.end; } static #getFormat( @@ -165,7 +179,7 @@ export class Compression extends Middleware { static readonly #formats = [DefaultCompression, "gzip", "deflate"] as const; /** The response depends on `Accept-Encoding` (keeps the existing `Vary` values) */ - static #varyOnAcceptEncoding({ response }: Context): void { + static #varyOnAcceptEncoding(response: Context["response"]): void { const raw = response.getHeader("Vary"); const values = ( Array.isArray(raw) ? raw : typeof raw === "undefined" ? [] : [`${raw}`] diff --git a/packages/compression/test/compression.test.ts b/packages/compression/test/compression.test.ts index e2feff83..f3e3a174 100644 --- a/packages/compression/test/compression.test.ts +++ b/packages/compression/test/compression.test.ts @@ -1,6 +1,7 @@ /* eslint-disable init-declarations, sort-imports */ import { deepStrictEqual, ok } from "node:assert"; import { randomBytes, randomUUID } from "node:crypto"; +import { once } from "node:events"; import { rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -466,7 +467,7 @@ describe("Compression", () => { }, ); - it("does not encode error responses of the following middlewares", async () => { + it("compresses error responses of the following middlewares", async () => { const message = "invalid request"; app.use(new Compression({ format: "gzip" }), () => { throw new BindenError(400, { expose: true, message }); @@ -475,10 +476,98 @@ describe("Compression", () => { const response = await request(url); deepStrictEqual(response.statusCode, 400); - ok(typeof response.headers["content-encoding"] === "undefined"); - deepStrictEqual(await response.body.text(), message); + deepStrictEqual(response.headers["content-encoding"], "gzip"); + + const body = await response.body.arrayBuffer(); + const actual = (await gunzip(Buffer.from(body))).toString(); + + deepStrictEqual(actual, message); + }); + + it("compresses an error thrown after the first write", async () => { + const message = "invalid request"; + app.use(new Compression({ format: "gzip" }), (ct: Context) => { + ct.response.write("prefix "); + throw new BindenError(400, { expose: true, message }); + }); + + const response = await request(url); + + deepStrictEqual(response.headers["content-encoding"], "gzip"); + + const body = await response.body.arrayBuffer(); + const actual = (await gunzip(Buffer.from(body))).toString(); + + ok(actual.startsWith("prefix ")); }); + it( + "keeps the backpressure of `response.write()`", + { timeout: 10_000 }, + async () => { + // Compressible data: the compressor fills up, the response does not + const expected = Buffer.alloc(1024 * 1024, "binden "); + const size = 64 * 1024; + const results: unknown[] = []; + app.use(new Compression({ format: "gzip" }), async (ct: Context) => { + for (let offset = 0; offset < expected.length; offset += size) { + const written = ct.response.write( + expected.subarray(offset, offset + size), + ); + results.push(written); + if (!written) { + await once(ct.response, "drain"); + } + } + await new Promise((resolve) => { + ct.response.end(resolve); + }); + }); + + const response = await request(url); + const body = Buffer.from(await response.body.arrayBuffer()); + + deepStrictEqual(await gunzip(body), expected); + ok(results.every((result) => typeof result === "boolean")); + ok(results.includes(false)); + }, + ); + + it( + "keeps the backpressure of `response.write()` (slow client)", + { timeout: 10_000 }, + async () => { + const expected = randomBytes(1024 * 1024); + const size = 64 * 1024; + const results: unknown[] = []; + app.use(new Compression({ format: "gzip" }), async (ct: Context) => { + for (let offset = 0; offset < expected.length; offset += size) { + const written = ct.response.write( + expected.subarray(offset, offset + size), + ); + results.push(written); + if (!written) { + await once(ct.response, "drain"); + } + } + await new Promise((resolve) => { + ct.response.end(resolve); + }); + }); + + const response = await request(url); + // A slow client fills the response, so the compressor has to wait as well + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + const body = Buffer.from(await response.body.arrayBuffer()); + + deepStrictEqual(await gunzip(body), expected); + ok(results.every((result) => typeof result === "boolean")); + ok(results.includes(false)); + }, + ); + it("does not compress when the headers have already been sent", async () => { const expected = "Hello World"; app.use(new Compression({ format: "gzip" }), (ct: Context) => { From c3a714c7b39b25d01b8a53145931c8b9d20f095d Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 19:16:09 +0300 Subject: [PATCH 028/675] fix: settle Multipart when the request is aborted in @binden/multipart `request.pipe(bb)` does not forward a closed connection to busboy, so a client disconnecting in the middle of a file part left `Multipart.run()` pending forever. Busboy is now destroyed when the request closes prematurely, which rejects the parsing promise once. Co-Authored-By: Claude Opus 5.5 --- packages/multipart/index.ts | 8 +++ packages/multipart/test/malformed.test.ts | 80 ++++++++++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/packages/multipart/index.ts b/packages/multipart/index.ts index 6a66e798..4c071faa 100644 --- a/packages/multipart/index.ts +++ b/packages/multipart/index.ts @@ -5,6 +5,7 @@ import { Middleware, } from "binden"; import busboy, { type BusboyConfig } from "busboy"; +import { finished } from "node:stream"; export type IMultipartConfig = Omit; @@ -121,6 +122,13 @@ export class Multipart extends Middleware { resolve(); } }); + // `pipe` does not forward a closed connection to busboy: destroy it, so + // that its `error` rejects this promise + finished(request, (error) => { + if (error) { + bb.destroy(error); + } + }); request.pipe(bb); }); } diff --git a/packages/multipart/test/malformed.test.ts b/packages/multipart/test/malformed.test.ts index 92237471..c0f72d0b 100644 --- a/packages/multipart/test/malformed.test.ts +++ b/packages/multipart/test/malformed.test.ts @@ -2,8 +2,8 @@ import { Binden, type Context } from "binden"; import { type Server, request } from "node:http"; import { afterEach, beforeEach, describe, it } from "node:test"; +import { deepEqual, ok } from "node:assert/strict"; import { Multipart } from "../index.js"; -import { deepEqual } from "node:assert/strict"; const port = 8080; const url = `http://localhost:${port}/`; @@ -76,3 +76,81 @@ describe("Multipart (malformed body)", () => { }); }); }); + +describe("Multipart (aborted request)", () => { + let server: Server; + let started: Promise; + let settled: Promise; + + beforeEach(async () => { + let start: () => void; + let settle: (value: unknown) => void; + started = new Promise((resolve) => { + start = resolve; + }); + settled = new Promise((resolve) => { + settle = resolve; + }); + + const parser = new Multipart(); + const app = new Binden().use(async (ct: Context) => { + start(); + try { + await parser.run(ct); + settle(null); + } catch (error: unknown) { + settle(error); + } + }); + await new Promise((resolve) => { + server = app.createServer().listen(port, resolve); + }); + }); + + it( + "Settles when the connection is closed in the middle of a file", + { timeout: 5000 }, + async () => { + const boundary = "binden-boundary"; + const head = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="file"; filename="file.txt"', + "Content-Type: text/plain", + "", + "Hello", + ].join("\r\n"); + const req = request(url, { + method: "POST", + headers: { + "Content-Type": `multipart/form-data; boundary=${boundary}`, + "Content-Length": Buffer.byteLength(head) + 1024, + }, + }); + req.on("error", () => { + // The connection is closed on purpose + }); + req.write(head); + + await started; + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + req.destroy(); + + ok((await settled) instanceof Error); + }, + ); + + afterEach(async () => { + server.closeAllConnections(); + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + }); +}); From 91c864333ecb98e16964c6450aa5b966f77a4858 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 20:32:17 +0300 Subject: [PATCH 029/675] fix: finish compressed responses together with the response in @binden/compression The callback of the replaced `end()` was passed to the compressor, so `send()` resolved as soon as the compressor had ended, before the response itself had finished. An error thrown after `await send()` then found `writableEnded === false` and the error handler wrote to the ended compressor, which crashed the process with `ERR_STREAM_WRITE_AFTER_END`; an `end()` callback that destroyed the response cut the compressed body. As for an uncompressed response, the `end()` callback now runs once the response has finished, `writableEnded` is set by `end()` and a second `end()` does nothing. The compressor is connected to the response with `pipeline()`, so an error of the chain destroys the response, and a response closed early (the client went away) destroys the compressor. Co-Authored-By: Claude Opus 5.5 --- packages/compression/index.ts | 79 ++++++++++------ packages/compression/test/compression.test.ts | 94 +++++++++++++++++++ 2 files changed, 146 insertions(+), 27 deletions(-) diff --git a/packages/compression/index.ts b/packages/compression/index.ts index f0135f69..7b5262fa 100644 --- a/packages/compression/index.ts +++ b/packages/compression/index.ts @@ -1,6 +1,6 @@ import { Context, type IMiddlewareParams, Middleware } from "binden"; +import { Writable, pipeline } from "node:stream"; import { createBrotliCompress, createDeflate, createGzip } from "node:zlib"; -import { Writable } from "node:stream"; export const DefaultCompression = "br"; @@ -64,6 +64,7 @@ export class Compression extends Middleware { // uncompressed body (e.g. from `sendFile`) is dropped, and nothing is // compressed when the headers have already been sent. let active: boolean | null = null; + let ended = false; const activate = (): boolean => { if (active === null) { active = !response.headersSent; @@ -77,24 +78,32 @@ export class Compression extends Middleware { ); response.removeHeader("Content-Length"); // Backpressure in both directions: a compressed chunk is acknowledged - // once the response has flushed it (so `pipe` pauses the compressor - // while the response is full), and `drain` of the compressor is the - // `drain` that writers of the response wait for. - compress - .on("drain", () => response.emit("drain")) - .pipe( - new Writable({ - write(chunk, _encoding, callback): void { - write(chunk, () => { - callback(); - }); - }, - final(callback): void { - end(); + // once the response has flushed it (so the compressor waits while the + // response is full), and `drain` of the compressor is the `drain` + // that writers of the response wait for. Errors of the chain and a + // response closed early (client gone) end both sides. + pipeline( + compress.on("drain", () => response.emit("drain")), + new Writable({ + write(chunk, _encoding, callback): void { + write(chunk, () => { callback(); - }, - }), - ); + }); + }, + final(callback): void { + end(); + callback(); + }, + }), + (error) => { + if (error) { + response.destroy(); + } + }, + ); + response.once("close", () => { + compress.destroy(); + }); } } return active; @@ -111,15 +120,31 @@ export class Compression extends Middleware { return write(chunk, encoding, cb); }) as typeof response.write; - response.end = (( - chunk?: unknown, - encoding?: BufferEncoding, - cb?: () => void, - ): typeof response => { - if (activate()) { - compress.end(chunk, encoding as BufferEncoding, cb); - } else { - end(chunk, encoding, cb); + // As for an uncompressed response, `writableEnded` is set by `end()`, the + // `end()` callback runs once the response has finished, and a second + // `end()` does nothing. + Object.defineProperty(response, "writableEnded", { + configurable: true, + get: () => ended, + }); + + response.end = ((...args: unknown[]): typeof response => { + const callback = args.find((arg) => typeof arg === "function"); + if (typeof callback === "function") { + response.once("finish", () => { + (callback as () => void)(); + }); + } + if (!ended) { + ended = true; + const [chunk, encoding] = args.filter( + (arg) => typeof arg !== "function", + ) as [unknown, BufferEncoding]; + if (activate()) { + compress.end(chunk, encoding); + } else { + end(chunk, encoding); + } } return response; }) as typeof response.end; diff --git a/packages/compression/test/compression.test.ts b/packages/compression/test/compression.test.ts index f3e3a174..567d27b1 100644 --- a/packages/compression/test/compression.test.ts +++ b/packages/compression/test/compression.test.ts @@ -6,6 +6,7 @@ import { rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Server } from "node:http"; +import { Readable } from "node:stream"; import { afterEach, beforeEach, describe, it } from "node:test"; import { type InputType, @@ -568,6 +569,99 @@ describe("Compression", () => { }, ); + it("finishes the response before `send()` resolves (an error thrown afterwards)", async () => { + const events: string[] = []; + app.use(new Compression(), async (ct: Context) => { + ct.response.once("finish", () => events.push("finish")); + await ct.send("ok"); + events.push("resolved"); + throw new BindenError(400, { expose: true, message: "late error" }); + }); + + const headers = { "Accept-Encoding": "br" }; + const response = await request(url, { headers }); + + deepStrictEqual(response.statusCode, 200); + const body = await response.body.arrayBuffer(); + deepStrictEqual((await brotliDecompress(body)).toString(), "ok"); + deepStrictEqual(events, ["finish", "resolved"]); + }); + + it("calls the `end()` callback once the response has finished", async () => { + app.use(new Compression({ format: "gzip" }), (ct: Context) => { + ct.response.end("ok", () => { + ct.response.destroy(); + }); + ct.done = true; + }); + + const response = await request(url); + const body = await response.body.arrayBuffer(); + + deepStrictEqual((await gunzip(Buffer.from(body))).toString(), "ok"); + }); + + it("ignores a second `end()`", async () => { + app.use(new Compression({ format: "gzip" }), (ct: Context) => { + ct.response.end("first"); + ct.response.end("second"); + ct.done = true; + }); + + const response = await request(url); + const body = await response.body.arrayBuffer(); + + deepStrictEqual((await gunzip(Buffer.from(body))).toString(), "first"); + }); + + it("reports `writableEnded` once `end()` has been called", async () => { + const states: boolean[] = []; + app.use(new Compression({ format: "gzip" }), async (ct: Context) => { + ct.done = true; + states.push(ct.response.writableEnded); + ct.response.end("first"); + states.push(ct.response.writableEnded); + // As for an uncompressed response, `send()` does nothing after `end()` + await ct.response.send(Readable.from(["second"])); + }); + + const response = await request(url); + const body = await response.body.arrayBuffer(); + + deepStrictEqual((await gunzip(Buffer.from(body))).toString(), "first"); + deepStrictEqual(states, [false, true]); + }); + + it( + "does not resolve `send()` when the client goes away", + { timeout: 10_000 }, + async () => { + let resolved = false; + let closed: Promise = Promise.resolve(); + app.use(new Compression({ format: "gzip" }), (ct: Context) => { + closed = once(ct.response, "close"); + ct.send(randomBytes(4 * 1024 * 1024)).then( + () => { + resolved = true; + }, + () => { + // A rejection is fine as well + }, + ); + ct.done = true; + }); + + const response = await request(url); + response.body.destroy(); + await closed; + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + + ok(!resolved); + }, + ); + it("does not compress when the headers have already been sent", async () => { const expected = "Hello World"; app.use(new Compression({ format: "gzip" }), (ct: Context) => { From 88a89cf87a175203599881ec971d9abf4c41d3a7 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 20:33:42 +0300 Subject: [PATCH 030/675] fix: do not compress partial responses in @binden/compression `sendFile()` with a `Range` request replied `206` with a `Content-Range` of the uncompressed file and a gzip-encoded body, which a client cannot reassemble (RFC 9110, section 14.1.2); a `416` got an encoded empty body as well. Responses with the `206` status or a `Content-Range` header are now sent unchanged, which the README documents. Co-Authored-By: Claude Opus 5.5 --- packages/compression/README.md | 2 + packages/compression/index.ts | 10 ++-- packages/compression/test/compression.test.ts | 53 +++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/compression/README.md b/packages/compression/README.md index a8e05a28..855ff03f 100644 --- a/packages/compression/README.md +++ b/packages/compression/README.md @@ -26,3 +26,5 @@ With the default `format: "auto"`, the content coding is chosen from the `Accept - `Vary: Accept-Encoding` is added to the response. A fixed `format` (`br`, `gzip` or `deflate`) compresses every response regardless of the request. + +Partial responses (`206 Partial Content`, or any response with a `Content-Range` header, such as `416 Range Not Satisfiable`) are never compressed, because their ranges refer to the uncompressed body. Neither are responses whose headers have already been sent. diff --git a/packages/compression/index.ts b/packages/compression/index.ts index 7b5262fa..abe604b3 100644 --- a/packages/compression/index.ts +++ b/packages/compression/index.ts @@ -61,13 +61,17 @@ export class Compression extends Middleware { const end = response.end.bind(response) as IEnd; // Compression starts with the first body chunk. A `Content-Length` of the - // uncompressed body (e.g. from `sendFile`) is dropped, and nothing is - // compressed when the headers have already been sent. + // uncompressed body (e.g. from `sendFile`) is dropped. Nothing is compressed + // when the headers have already been sent, nor for partial responses: their + // ranges refer to the uncompressed body (RFC 9110, section 14.1.2). let active: boolean | null = null; let ended = false; const activate = (): boolean => { if (active === null) { - active = !response.headersSent; + active = + !response.headersSent && + response.statusCode !== 206 && + !response.hasHeader("Content-Range"); if (active) { const raw = response.getHeader("Content-Encoding"); response.setHeader( diff --git a/packages/compression/test/compression.test.ts b/packages/compression/test/compression.test.ts index 567d27b1..7f17d37a 100644 --- a/packages/compression/test/compression.test.ts +++ b/packages/compression/test/compression.test.ts @@ -468,6 +468,59 @@ describe("Compression", () => { }, ); + it( + "does not compress partial responses (`Range`)", + { timeout: 10_000 }, + async () => { + const expected = randomBytes(1024).toString("hex"); + const path = join(tmpdir(), `binden-compression-${randomUUID()}.txt`); + await writeFile(path, expected); + + try { + app.use(new Compression({ format: "gzip" }), (ct: Context) => + ct.sendFile(path), + ); + + const partial = await request(url, { + headers: { Range: "bytes=0-9" }, + }); + + deepStrictEqual(partial.statusCode, 206); + ok(typeof partial.headers["content-encoding"] === "undefined"); + deepStrictEqual(partial.headers["content-length"], "10"); + deepStrictEqual(partial.headers["content-range"], "bytes 0-9/2048"); + deepStrictEqual(await partial.body.text(), expected.slice(0, 10)); + + const unsatisfiable = await request(url, { + headers: { Range: "bytes=4096-" }, + }); + + deepStrictEqual(unsatisfiable.statusCode, 416); + ok(typeof unsatisfiable.headers["content-encoding"] === "undefined"); + deepStrictEqual(unsatisfiable.headers["content-range"], "bytes */2048"); + deepStrictEqual(await unsatisfiable.body.text(), ""); + } finally { + await rm(path); + } + }, + ); + + it("does not compress `206` without `Content-Range` (multipart/byteranges)", async () => { + const expected = "--boundary--"; + app.use(new Compression({ format: "gzip" }), (ct: Context) => + ct + .status(206) + .setHeader("Content-Type", "multipart/byteranges; boundary=boundary") + .send(expected), + ); + + const response = await request(url); + + deepStrictEqual(response.statusCode, 206); + ok(typeof response.headers["content-encoding"] === "undefined"); + deepStrictEqual(await response.body.text(), expected); + }); + it("compresses error responses of the following middlewares", async () => { const message = "invalid request"; app.use(new Compression({ format: "gzip" }), () => { From e3bb6f39a2a07b89d37fef29e2660d0f97c4423b Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 20:46:30 +0300 Subject: [PATCH 031/675] feat: allow a custom logger in binden Every context logged through the module logger of binden, which only the `BINDEN_LOG_LEVEL` environment variable configured. `Binden` now takes a `logger` option (an `@binden/logger` `Logger`) whose child, with the `trace_id` of the context, is `context.log`; `Context` takes the same `logger` option. The default serializers are exported as `serializers`, so that a custom logger can build on them. The README documents what the default logger keeps out of the logs. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 14 +++++++++ packages/binden/index.ts | 1 + packages/binden/src/binden.ts | 14 +++++++-- packages/binden/src/context.ts | 13 +++++++-- packages/binden/test/binden.test.ts | 44 +++++++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 4 deletions(-) diff --git a/packages/binden/README.md b/packages/binden/README.md index fabb2b4d..17a5e258 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -55,6 +55,20 @@ const app = new Binden().use("/path", middleware).use("/path2", router); const secureServer = app.createSecureServer({ key, cert }); ``` +### Logging + +Every context logs through `context.log`: a child (with the `trace_id` of the context) of the logger of the application. The default logger (see [`@binden/logger`](https://github.com/binden-js/binden/tree/main/packages/logger)) is silent unless the `BINDEN_LOG_LEVEL` environment variable sets a level, and its `serializers` keep credentials out of the logs: the `Authorization`, `Cookie`, `Proxy-Authorization` and `Set-Cookie` headers are redacted, and the query string is dropped from the request URL. + +A custom logger (for example with another level or destination) is passed with the `logger` option. The default `serializers` are exported to build on: + +```typescript +import { Binden, serializers } from "binden"; +import { Logger } from "@binden/logger"; + +const logger = new Logger({ level: "debug", serializers }); +const app = new Binden({ logger }); +``` + ### Context - `.log` - get the logger (see [`@binden/logger`](https://github.com/binden-js/binden/tree/main/packages/logger)) diff --git a/packages/binden/index.ts b/packages/binden/index.ts index 66e5496a..dbf2b23d 100644 --- a/packages/binden/index.ts +++ b/packages/binden/index.ts @@ -14,3 +14,4 @@ export * from "./src/middleware.js"; export * from "./src/request.js"; export * from "./src/response.js"; export * from "./src/router.js"; +export { serializers } from "./src/logger.js"; diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index 1899ffc2..d62f16df 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -1,8 +1,10 @@ import http, { STATUS_CODES } from "node:http"; import https from "node:https"; import { isDeepStrictEqual } from "node:util"; +import type { Logger } from "@binden/logger"; import { Context } from "./context.js"; import { BindenError } from "./error.js"; +import defaultLogger from "./logger.js"; import { Middleware, type IMiddleware } from "./middleware.js"; import { BindenRequest } from "./request.js"; import { BindenResponse, ct_text, ct_json } from "./response.js"; @@ -18,17 +20,25 @@ export const DefaultErrorCode = 500; export interface IBindenOptions { auto_head?: boolean; error_handler?: (context: Context, error: unknown) => void; + /** The logger of the contexts (the default one is set up by `BINDEN_LOG_LEVEL`) */ + logger?: Logger; } export class Binden { readonly #stack: IStack[]; readonly #auto_head: boolean; readonly #error_handler: ((context: Context, error: unknown) => void) | null; + readonly #logger: Logger; - public constructor({ error_handler, auto_head = true }: IBindenOptions = {}) { + public constructor({ + error_handler, + auto_head = true, + logger = defaultLogger, + }: IBindenOptions = {}) { this.#stack = []; this.#error_handler = error_handler ?? null; this.#auto_head = auto_head; + this.#logger = logger; } public get stack(): IStack[] { @@ -169,7 +179,7 @@ export class Binden { request: BindenRequest, response: BindenResponse, ): void { - const context = new Context({ request, response }); + const context = new Context({ request, response, logger: this.#logger }); this.#handle(context).catch((error: unknown) => { this.#errorHandler(context, error); }); diff --git a/packages/binden/src/context.ts b/packages/binden/src/context.ts index 2d118913..ef433315 100644 --- a/packages/binden/src/context.ts +++ b/packages/binden/src/context.ts @@ -1,6 +1,6 @@ import { Logger } from "@binden/logger"; import { BindenError, type IBindenErrorOptions } from "./error.js"; -import logger from "./logger.js"; +import defaultLogger from "./logger.js"; import type { Readable } from "node:stream"; import type { BindenRequest } from "./request.js"; @@ -11,13 +11,22 @@ export interface IBaseContext { readonly response: BindenResponse; } +export interface IContextOptions extends IBaseContext { + /** The logger whose child (with the `trace_id` of the context) is `context.log` */ + readonly logger?: Logger; +} + export class Context implements IBaseContext { readonly #request: BindenRequest; readonly #response: BindenResponse; readonly #logger: Logger; #done: boolean; - public constructor({ request, response }: IBaseContext) { + public constructor({ + request, + response, + logger = defaultLogger, + }: IContextOptions) { this.#request = request; this.#response = response; this.#done = false; diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index 75a35756..5377032a 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -5,6 +5,7 @@ import { readFile } from "node:fs/promises"; import { Server } from "node:http"; import { Agent } from "node:https"; import { afterEach, beforeEach, suite, test } from "node:test"; +import { Logger } from "@binden/logger"; import fetch from "node-fetch"; import { @@ -19,6 +20,7 @@ import { ct_text, ct_json, ct_html, + serializers, } from "../index.js"; class CustomMiddleware extends Middleware { @@ -206,6 +208,48 @@ suite("Binden", () => { deepStrictEqual(app.stack, []); }); + test("custom `logger`", async () => { + const lines: Record[] = []; + const destination = { + write: (line: string): void => { + lines.push(JSON.parse(line) as Record); + }, + }; + const logger = new Logger({ destination, level: "debug", serializers }); + const newApp = new Binden({ logger }); + const path = new URL(url); + path.port = `${Number(path.port) + 1}`; + + const newServer = newApp.createServer(); + + await new Promise((resolve) => { + newServer.listen(port + 1, resolve); + }); + + try { + let id = ""; + newApp.use((context: Context) => { + ({ id } = context); + context.log.info("Custom logger"); + throw new BindenError(401); + }); + + const response = await fetch(path.toString()); + deepStrictEqual(response.status, 401); + deepStrictEqual( + lines.map(({ msg, trace_id }) => ({ msg, trace_id })), + [ + { msg: "Custom logger", trace_id: id }, + { msg: "Error should not be exposed", trace_id: id }, + ], + ); + } finally { + await new Promise((resolve) => { + newServer.close(resolve); + }); + } + }); + test("Disabled middleware", async () => { const m1 = new SendMiddleware({ disabled: true }); const m2 = new SendMiddleware(); From 27d0300af58dc9612e15c781636d498215b81efe Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 20:49:24 +0300 Subject: [PATCH 032/675] fix: keep request bodies and error details out of the default binden logs `@binden/body` logs the body it failed to parse, and the default error serializer of binden kept the message, stack, cause and `json` of the errors: the message of a `JSON.parse()` error quotes the body, so a password sent in an invalid JSON request ended up in the debug logs twice. The default serializers now redact a logged `body` and reduce an `error` to its `type`, `status`, `expose` and `code`. Both can be logged again with a custom logger built on the exported `serializers`, which the README shows; an HTTP test of `@binden/body` checks both setups. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 1 + packages/binden/README.md | 17 ++++++- packages/binden/src/logger.ts | 29 ++++++++--- packages/binden/test/logger.test.ts | 59 ++++++++++++++++++----- packages/body/package.json | 1 + packages/body/test/logging.test.ts | 74 +++++++++++++++++++++++++++++ 6 files changed, 159 insertions(+), 22 deletions(-) create mode 100644 packages/body/test/logging.test.ts diff --git a/package-lock.json b/package-lock.json index a08088ec..0d9fff30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9268,6 +9268,7 @@ "version": "3.0.0", "license": "AGPL-3.0-only", "devDependencies": { + "@binden/logger": "^3.0.0", "ajv": "^8.20.0", "undici": "^7.11.0" }, diff --git a/packages/binden/README.md b/packages/binden/README.md index 17a5e258..f9ed89b1 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -57,9 +57,13 @@ const secureServer = app.createSecureServer({ key, cert }); ### Logging -Every context logs through `context.log`: a child (with the `trace_id` of the context) of the logger of the application. The default logger (see [`@binden/logger`](https://github.com/binden-js/binden/tree/main/packages/logger)) is silent unless the `BINDEN_LOG_LEVEL` environment variable sets a level, and its `serializers` keep credentials out of the logs: the `Authorization`, `Cookie`, `Proxy-Authorization` and `Set-Cookie` headers are redacted, and the query string is dropped from the request URL. +Every context logs through `context.log`: a child (with the `trace_id` of the context) of the logger of the application. The default logger (see [`@binden/logger`](https://github.com/binden-js/binden/tree/main/packages/logger)) is silent unless the `BINDEN_LOG_LEVEL` environment variable sets a level, and its `serializers` keep credentials and the input of the requests out of the logs: -A custom logger (for example with another level or destination) is passed with the `logger` option. The default `serializers` are exported to build on: +- the `Authorization`, `Cookie`, `Proxy-Authorization` and `Set-Cookie` headers are redacted, and the query string is dropped from the request URL; +- a logged `body` (e.g. the one that [`@binden/body`](https://github.com/binden-js/binden/tree/main/packages/body) failed to parse) is redacted; +- an `error` is reduced to its `type`, `status`, `expose` and `code`: its message, stack, cause and `json` may quote the input (e.g. an error of `JSON.parse()`). + +A custom logger (for example with another level or destination, or to log more details) is passed with the `logger` option. The default `serializers` are exported to build on: ```typescript import { Binden, serializers } from "binden"; @@ -67,8 +71,17 @@ import { Logger } from "@binden/logger"; const logger = new Logger({ level: "debug", serializers }); const app = new Binden({ logger }); + +// Log the request bodies as well +const verbose = new Logger({ + level: "debug", + serializers: { ...serializers, body: (body: unknown): unknown => body }, +}); +const debugApp = new Binden({ logger: verbose }); ``` +Likewise, `error` can be replaced with a serializer that keeps the details of the errors (e.g. `stdSerializers.err` of [pino](https://getpino.io/#/docs/api?id=pino-stdserializers)). + ### Context - `.log` - get the logger (see [`@binden/logger`](https://github.com/binden-js/binden/tree/main/packages/logger)) diff --git a/packages/binden/src/logger.ts b/packages/binden/src/logger.ts index e245b457..d573a231 100644 --- a/packages/binden/src/logger.ts +++ b/packages/binden/src/logger.ts @@ -1,6 +1,5 @@ import Pino from "pino"; import { Logger } from "@binden/logger"; -import { BindenError } from "./error.js"; import { Context } from "./context.js"; const redacted = "[Redacted]"; @@ -22,14 +21,30 @@ function redact(headers: Record): Record { ); } +/** + * The serializers of the default logger, which keep credentials, query strings, + * request bodies and error details (messages, stacks, causes and `json`, which + * may quote the input, e.g. the one of `JSON.parse()`) out of the logs + */ export const serializers = { - error: Pino.stdSerializers.wrapErrorSerializer((error) => { - if (error.raw instanceof BindenError) { - const { status, expose, json } = error.raw; - return { status, expose, json, ...error }; + // Only the kind of an error: its type, `status`, `expose` and `code` + error: (error: unknown): Record => { + if (!(error instanceof Error)) { + return { type: typeof error }; } - return error; - }), + const { status, expose, code } = error as { + status?: unknown; + expose?: unknown; + code?: unknown; + }; + return { + type: error.constructor.name, + ...(typeof status === "number" ? { status } : {}), + ...(typeof expose === "boolean" ? { expose } : {}), + ...(typeof code === "string" || typeof code === "number" ? { code } : {}), + }; + }, + body: (): string => redacted, // The query string (and the parsed `query`/`params`) may contain tokens request: Pino.stdSerializers.wrapRequestSerializer( /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ diff --git a/packages/binden/test/logger.test.ts b/packages/binden/test/logger.test.ts index d9fb2a5c..8e7dfbe2 100644 --- a/packages/binden/test/logger.test.ts +++ b/packages/binden/test/logger.test.ts @@ -16,6 +16,10 @@ const secrets = [ "secret-cookie", "secret-query", "secret-set-cookie", + "secret-body", + "secret-message", + "secret-cause", + "secret-json", ]; function createContext(): Context { @@ -81,18 +85,43 @@ suite("serializers", () => { deepStrictEqual(serializers.context("not a context"), "not a context"); }); - test("error", () => { - const error = serializers.error( - new BindenError(400, { expose: true, json: { message: "Bad" } }), - ); - deepStrictEqual( - [error.status, error.expose, error.json], - [400, true, { message: "Bad" }], - ); - - const plain = serializers.error(new Error("plain")); - deepStrictEqual(plain.message, "plain"); - ok(!("status" in plain)); + test("error: no message, stack, cause or json", () => { + const cause = new SyntaxError("secret-cause"); + const error = new BindenError(400, { + expose: true, + message: "secret-message", + json: { message: "secret-json" }, + cause, + }); + deepStrictEqual(serializers.error(error), { + type: "BindenError", + status: 400, + expose: true, + }); + + const system = Object.assign(new Error("secret-message", { cause }), { + code: "ECONNRESET", + }); + deepStrictEqual(serializers.error(system), { + type: "Error", + code: "ECONNRESET", + }); + + const numeric = Object.assign(new TypeError("secret-message"), { + code: 42, + status: "400", + expose: "true", + }); + deepStrictEqual(serializers.error(numeric), { + type: "TypeError", + code: 42, + }); + + deepStrictEqual(serializers.error("secret-message"), { type: "string" }); + }); + + test("body", () => { + deepStrictEqual(serializers.body(), "[Redacted]"); }); test("framework logs do not leak credentials", () => { @@ -106,7 +135,11 @@ suite("serializers", () => { log.debug("Not found", { context: createContext(), - error: new BindenError(404), + body: { password: "secret-body" }, + error: new BindenError(404, { + message: "secret-message", + cause: new Error("secret-cause"), + }), }); deepStrictEqual(lines.length, 1); diff --git a/packages/body/package.json b/packages/body/package.json index 68b7272d..31cbb769 100644 --- a/packages/body/package.json +++ b/packages/body/package.json @@ -39,6 +39,7 @@ "binden": "^3.0.0" }, "devDependencies": { + "@binden/logger": "^3.0.0", "ajv": "^8.20.0", "undici": "^7.11.0" }, diff --git a/packages/body/test/logging.test.ts b/packages/body/test/logging.test.ts new file mode 100644 index 00000000..b485fda3 --- /dev/null +++ b/packages/body/test/logging.test.ts @@ -0,0 +1,74 @@ +import { deepStrictEqual, ok } from "node:assert/strict"; +import type { Server } from "node:http"; +import { suite, test } from "node:test"; +import { Logger } from "@binden/logger"; +import { Binden, ct_json, serializers } from "binden"; +import { request } from "undici"; + +import { BodyParser } from "../index.js"; + +const port = 8080; +const url = `http://localhost:${port}`; +// Invalid JSON: the error of `JSON.parse()` quotes the body +const body = '{"password": SECRET}'; + +/** Posts the body and returns the response status and the log lines */ +async function post( + logger: (destination: { write(line: string): void }) => Logger, +): Promise<[number, string]> { + const lines: string[] = []; + const destination = { + write: (line: string): void => { + lines.push(line); + }, + }; + const app = new Binden({ logger: logger(destination) }).use(new BodyParser()); + const server: Server = app.createServer(); + await new Promise((resolve) => { + server.listen(port, resolve); + }); + + try { + const response = await request(url, { + method: "POST", + headers: { "Content-Type": ct_json }, + body, + }); + await response.body.dump(); + return [response.statusCode, lines.join("")]; + } finally { + await new Promise((resolve) => { + server.close(resolve); + }); + } +} + +suite("BodyParser (logging)", () => { + test("keeps the body out of the default logs", async () => { + const [status, output] = await post( + (destination) => new Logger({ serializers, destination, level: "debug" }), + ); + + deepStrictEqual(status, 415); + ok(output.includes("Request body does not match provided Content-Type")); + ok(output.includes('"body":"[Redacted]"'), output); + ok(!output.includes("SECRET"), output); + }); + + test("logs the body with a custom serializer", async () => { + const [status, output] = await post( + (destination) => + new Logger({ + serializers: { + ...serializers, + body: (input: unknown): unknown => input, + }, + destination, + level: "debug", + }), + ); + + deepStrictEqual(status, 415); + ok(output.includes(`"body":${JSON.stringify(body)}`), output); + }); +}); From c3cb8176afbe38007c105404ae199ea48b883815 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 20:51:25 +0300 Subject: [PATCH 033/675] docs: fix the README examples Several examples did not compile: variants marked "or" redeclared the same constants, `fast-json-stringify` was called as the module namespace (and its `stringify` was not passed), the `Authorization` example parsed with `AcceptEncoding`, a middleware used `await` outside of an async method, the query example used `this.URL`, `server` and `data` were never declared, and the examples of `@binden/body` and `@binden/multipart` missed the imports of `binden`. The `.off()` example now shows that it returns the removed items, and two typos of the `stringify` paragraph are fixed. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 53 +++++++++++++++++++----------------- packages/body/README.md | 1 + packages/multipart/README.md | 6 ++-- 3 files changed, 33 insertions(+), 27 deletions(-) diff --git a/packages/binden/README.md b/packages/binden/README.md index f9ed89b1..90c1cdce 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -24,15 +24,14 @@ const middleware3 = (context) => context.json({ message: "Hello World!" }); app.use("/path2", middleware3); ``` -- `.off()` - remove a `Middleware/Router` form the stack +- `.off()` - remove a `Middleware/Router` from the stack (returns the removed items) ```typescript import { Binden } from "binden"; -const app = new Binden() - .use("/path", middleware1) - .use(middleware2) - .off("/path", middleware1); +const app = new Binden().use("/path", middleware1).use(middleware2); +// `.off()` returns the removed items +const removed = app.off("/path", middleware1); ``` - `.createServer()` - create a server (HTTP) @@ -126,6 +125,8 @@ const { id } = context; - `.done` ```typescript +import { type Context, Middleware } from "binden"; + class MyMiddleware extends Middleware { public run(context: Context): void { context.done = true; // Stop passing the `context` to other middlewares @@ -154,6 +155,7 @@ log.trace("url search string", { search }); - `.send()` - execute `context.response.send()` and set `context.done` to `true` ```typescript +const data = "Hello World!"; await context.send(data); // or await context.response.send(data); @@ -170,11 +172,10 @@ await context.response.json(json); context.done = true; ``` -A custom `stringify` function (e.g. [fast-json-stringify](https://github.com/fastify/fast-json-stringify-stringify)) can pan passed as the second argument. +A custom `stringify` function (e.g. [fast-json-stringify](https://github.com/fastify/fast-json-stringify)) can be passed as the second argument. ```typescript -const json = { currency: "💶", value: 120 }; -const fastJSON = await import("fast-json-stringify"); +const { default: fastJSON } = await import("fast-json-stringify"); const stringify = fastJSON({ title: "Example Schema", type: "object", @@ -252,7 +253,7 @@ import { Middleware, Context } from "binden"; export class MyMiddleware extends Middleware { public async run(context: Context): Promise { - const randomNumber = await new Promise((resolve, reject) => { + const randomNumber = await new Promise((resolve, reject) => { randomInt(1, 100, (error, n) => { if (error) { reject(error); @@ -334,7 +335,7 @@ export class MyMiddleware2 extends Middleware { import { Middleware, Context, BindenError } from "binden"; export class MyMiddleware extends Middleware { - public run(context: Context): Promise { + public async run(context: Context): Promise { const { length } = context.request.cookies; if (!length) { @@ -369,7 +370,7 @@ Simple usage with `http` ```typescript import { createServer } from "http"; import { BindenRequest } from "binden"; -server = createServer({ IncomingMessage: BindenRequest }); +const server = createServer({ IncomingMessage: BindenRequest }); ``` - `.header()` - get a header by name @@ -405,9 +406,11 @@ if (!secure) { - `.query` - A copy of `URL.search` parsed by the `querystring.parse()` method ```typescript +import { parse } from "node:querystring"; + const { query } = request; // same as -const query = { ...parse(this.URL.search.substring(1)) }; +const parsed = { ...parse(request.URL.search.substring(1)) }; ``` ### BindenResponse @@ -417,7 +420,7 @@ Simple usage with `http` ```typescript import { createServer } from "http"; import { BindenResponse } from "binden"; -server = createServer({ ServerResponse: BindenResponse }); +const server = createServer({ ServerResponse: BindenResponse }); ``` - `.cookies` - The `.send()` method will add cookies to the response @@ -468,7 +471,7 @@ or using a custom `stringify` function ```typescript const json = { currency: "💶", value: 120 }; -const fastJSON = await import("fast-json-stringify"); +const { default: fastJSON } = await import("fast-json-stringify"); const stringify = fastJSON({ title: "Example Schema", type: "object", @@ -483,7 +486,7 @@ const stringify = fastJSON({ required: ["currency", "value"], additionalProperties: false, }); -await response.json(json); +await response.json(json, stringify); ``` - `.text()` - send text as `text/plain` @@ -503,7 +506,11 @@ await response.html(html); - `.form()` - send `URLSearchParams`; ```typescript -const form = new URLSearchParams({ a: "1", b: ["a", "c"] }); +const form = new URLSearchParams([ + ["a", "1"], + ["b", "a"], + ["b", "c"], +]); await response.form(form); ``` @@ -540,9 +547,7 @@ const { accept_encoding } = request; ```typescript import { Authorization } from "binden"; -const authorization = AcceptEncoding.fromString( - request.headers["Authorization"], -); +const credentials = Authorization.fromString(request.headers.authorization); // or using BindenRequest const { authorization } = request; ``` @@ -583,7 +588,7 @@ const { content_type } = request; ```typescript import { Cookie } from "binden"; -const cookies = Cookie.fromString(request.headers["cookie"]); +const parsed = Cookie.fromString(request.headers.cookie); // or using BindenRequest const { cookies } = request; // or using BindenResponse @@ -606,7 +611,7 @@ response.cookies.add(cookie1).add(cookie2); ```typescript import { Forwarded } from "binden"; -const forwarded = Forwarded.fromString(request.headers["forwarded"]); +const proxies = Forwarded.fromString(request.headers.forwarded); // or using BindenRequest const { forwarded } = request; ``` @@ -616,9 +621,7 @@ const { forwarded } = request; ```typescript import { IfModifiedSince } from "binden"; -const if_modified_since = IfModifiedSince.fromString( - request.headers["if-modified-since"], -); +const ims = IfModifiedSince.fromString(request.headers["if-modified-since"]); // or using BindenRequest const { if_modified_since } = request; ``` @@ -628,7 +631,7 @@ const { if_modified_since } = request; ```typescript import { Range } from "binden"; -const range = Range.fromString(request.headers.range); +const ranges = Range.fromString(request.headers.range); // or using BindenRequest const { range } = request; ``` diff --git a/packages/body/README.md b/packages/body/README.md index b785f291..13403b8c 100644 --- a/packages/body/README.md +++ b/packages/body/README.md @@ -22,6 +22,7 @@ npm install @binden/body ## Usage ```typescript +import { type Context, Middleware, Router } from "binden"; import BodyParser from "@binden/body"; class MyMiddleware extends Middleware { diff --git a/packages/multipart/README.md b/packages/multipart/README.md index 64ab19f7..169fed4c 100644 --- a/packages/multipart/README.md +++ b/packages/multipart/README.md @@ -11,11 +11,12 @@ npm install @binden/multipart ## Usage ```typescript +import { type Context, Router } from "binden"; import multipart from "@binden/multipart"; const formDataParser = multipart({ limits: { fields: 2, fileSize: 1024 } }); -const next = (context) => { +const next = (context: Context): Promise => { if (context.request.body instanceof FormData) { context.log.info("Body has been parsed successfully"); return context.status(200).text("GOOD"); @@ -33,6 +34,7 @@ app.use("/formdata", new Router().post(formDataParser, next)); Set the `throw_limits` to `false` to avoid throwing errors. `FormData` might be truncated due to `config.limits` (See `busboy` [limits](https://github.com/mscdex/busboy/#api)) ```typescript +import { type Context, Router } from "binden"; import { Multipart } from "@binden/multipart"; const formDataParser = new Multipart({ @@ -49,7 +51,7 @@ const formDataParser = new Multipart({ }, }); -const next = (context) => { +const next = (context: Context): Promise => { if (context.request.body instanceof FormData) { context.log.info("Fields and files might have been truncated"); return context.status(200).text("MAYBE GOOD"); From ba1efec4f45259a7f71ccd071a5e2990c115127b Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 20:53:27 +0300 Subject: [PATCH 034/675] ci: type check the README examples TypeDoc does not compile the code blocks of the READMEs, so their examples could drift from the API without CI noticing. `consumer:check` now writes every `typescript` block of the workspace READMEs as a module and type checks them against the installed tarballs (with `@types/node` 24 and 26, and the `ajv` and `fast-json-stringify` versions the tests use). A few placeholders (`app`, `context`, `request`, ...) are declared globally; the check fails when no example is found. Co-Authored-By: Claude Opus 5.5 --- README.md | 15 +++---- scripts/consumer-check.mjs | 85 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index b5e62457..7e172268 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,14 @@ API documentation: Requires Node.js `>=24.15.0` and npm `>=11.11.0`. ```bash -npm install # installs all workspaces and builds them (`prepare`) -npm run build # type checks and compiles every workspace (including tests) -npm test # node:test (via tsx) in every workspace -npm run coverage # tests with c8 coverage (coverage/lcov.info) -npm run eslint # type-aware ESLint for the whole repository -npm run docs:build # TypeDoc for all packages (docs/) -npm run ci:all # everything CI runs +npm install # installs all workspaces and builds them (`prepare`) +npm run build # type checks and compiles every workspace (including tests) +npm test # node:test (via tsx) in every workspace +npm run coverage # tests with c8 coverage (coverage/lcov.info) +npm run eslint # type-aware ESLint for the whole repository +npm run docs:build # TypeDoc for all packages (docs/) +npm run consumer:check # the packed packages and the README examples in a fresh project +npm run ci:all # everything CI runs ``` Commands for a single package: `npm test -w packages/binden`. diff --git a/scripts/consumer-check.mjs b/scripts/consumer-check.mjs index a1c0bb5c..e246d347 100644 --- a/scripts/consumer-check.mjs +++ b/scripts/consumer-check.mjs @@ -1,12 +1,14 @@ #!/usr/bin/env node // Checks the packed workspaces the way a consumer uses them: the tarballs are // installed into a fresh project, which is type checked (with `@types/node` 24 -// and 26) and then run as ESM against a real server. +// and 26) and then run as ESM against a real server. The TypeScript examples of +// the READMEs are type checked against the same installation. // // `@types/node` 26 is checked with `skipLibCheck`: the types of `thread-stream` // (pulled in by `pino`, used by `@binden/logger`) still refer to // `worker_threads.TransferListItem`, which `@types/node` 26 removed. import { + mkdirSync, mkdtempSync, readFileSync, readdirSync, @@ -18,9 +20,14 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; const root = join(import.meta.dirname, ".."); -const { devDependencies } = JSON.parse( - readFileSync(join(root, "package.json"), "utf8"), -); +const readJSON = (path) => JSON.parse(readFileSync(join(root, path), "utf8")); +const { devDependencies, workspaces } = readJSON("package.json"); +// Packages that the examples import besides the workspaces +const exampleDependencies = { + ajv: readJSON("packages/body/package.json").devDependencies.ajv, + "fast-json-stringify": readJSON("packages/binden/package.json") + .devDependencies["fast-json-stringify"], +}; const consumer = `import { once } from "node:events"; import type { AddressInfo } from "node:net"; @@ -93,8 +100,50 @@ try { } `; +// The placeholders that the examples use without declaring them +const globals = `import type { Binden, BindenRequest, BindenResponse, Context, IMiddleware, Router } from "binden"; + +declare global { + const app: Binden; + const context: Context; + const request: BindenRequest; + const response: BindenResponse; + const middleware: IMiddleware; + const middleware1: IMiddleware; + const middleware2: IMiddleware; + const router: Router; + const router2: Router; + const router3: Router; + const key: string; + const cert: string; + function validateBody(): Promise; +} +`; + const dir = mkdtempSync(join(tmpdir(), "binden-consumer-")); +/** Writes every `typescript` block of the READMEs as a module of `examples/` */ +function writeExamples() { + const examples = join(dir, "examples"); + mkdirSync(examples); + let count = 0; + for (const workspace of workspaces) { + const readme = readFileSync(join(root, workspace, "README.md"), "utf8"); + const name = workspace.split("/").at(-1); + const blocks = readme.matchAll(/^```typescript\n(?[\s\S]*?)^```$/gmu); + for (const [index, { groups }] of [...blocks].entries()) { + writeFileSync(join(examples, `${name}-${index + 1}.ts`), groups.code); + count += 1; + } + } + if (count === 0) { + throw new Error("No TypeScript examples found in the READMEs"); + } + writeFileSync(join(examples, "globals.d.ts"), globals); + process.stderr.write(`[consumer] ${count} README examples\n`); + return examples; +} + function run(command, args, cwd = dir) { process.stderr.write(`[consumer] ${command} ${args.join(" ")}\n`); execFileSync(command, args, { cwd, stdio: ["ignore", "inherit", "inherit"] }); @@ -115,6 +164,7 @@ try { `${JSON.stringify({ private: true, type: "module" })}\n`, ); writeFileSync(join(dir, "consumer.ts"), consumer); + const examples = writeExamples(); for (const [types, skipLibCheck] of [ ["24", false], @@ -128,6 +178,9 @@ try { ...tarballs, `typescript@${devDependencies.typescript}`, `@types/node@${types}`, + ...Object.entries(exampleDependencies).map( + ([name, version]) => `${name}@${version}`, + ), ]); writeFileSync( join(dir, "tsconfig.json"), @@ -149,6 +202,30 @@ try { dir, ]); run(process.execPath, [join(dir, "dist/consumer.js")]); + + // Each example is a module (`moduleDetection`), and the parameters of their + // callbacks may be untyped (`noImplicitAny`) + writeFileSync( + join(examples, "tsconfig.json"), + `${JSON.stringify({ + compilerOptions: { + module: "nodenext", + target: "es2022", + strict: true, + noImplicitAny: false, + moduleDetection: "force", + noEmit: true, + skipLibCheck, + types: ["node"], + }, + include: ["*.ts"], + })}\n`, + ); + run(process.execPath, [ + join(dir, "node_modules/typescript/bin/tsc"), + "-p", + examples, + ]); } process.stderr.write("[consumer] OK\n"); } finally { From 7a1a1175d361f37afb3f399161ecb9c3ede29dcc Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 21:32:11 +0300 Subject: [PATCH 035/675] fix: settle send() when the response is closed early in binden `send()` waited for the callback of `end()` (or for the end of a `Readable`), which never comes when the response is closed before it has finished: the promise stayed pending after `response.destroy()`, and an endless or slow `Readable` (e.g. `sendFile()` to a client that went away) was neither settled nor released. `send()` now resolves on `finish`, and rejects with `ERR_STREAM_PREMATURE_CLOSE` when the response is closed before the client has it: before `end()` has handed it over to the connection (`end()` records it), or, for a `Readable`, before the client has received the whole `Content-Length` (a `HEAD` response has no body). A client may close the connection as soon as it has the response, before `finish` or even before the source has ended: that close is not premature. `stream.finished()` is not used, since it treats a `ServerResponse` as a legacy stream, with results that depend on the Node.js version and on these races. A `Readable` that has not ended when the response closes is destroyed, which releases its file; its own error still rejects without destroying the response, so the error handler can reply. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/response.ts | 134 +++++++++++++++-- packages/binden/test/response.test.ts | 208 ++++++++++++++++++++++++-- packages/compression/index.ts | 4 +- 3 files changed, 313 insertions(+), 33 deletions(-) diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index ba0ffb48..8fc5268b 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -22,6 +22,7 @@ export class BindenResponse< Request extends IncomingMessage = IncomingMessage, > extends ServerResponse { #cookies?: Set; + #ended = false; public get cookies(): Set { if (!this.#cookies) { @@ -30,6 +31,24 @@ export class BindenResponse< return this.#cookies; } + /** + * End the response. Once called, the response is handed over to the connection: + * a close that comes before `finish` is no longer premature (see `.send()`). + */ + public override end(cb?: () => void): this; + /** End the response with a last `chunk` (see the first signature) */ + public override end(chunk: unknown, cb?: () => void): this; + /** End the response with a last `chunk` in an `encoding` (see the first signature) */ + public override end( + chunk: unknown, + encoding: BufferEncoding, + cb?: () => void, + ): this; + public override end(...args: unknown[]): this { + this.#ended = true; + return super.end(...(args as [unknown, BufferEncoding])); + } + /** Set the status code */ public status(code: number): this { if (typeof STATUS_CODES[code] === "undefined") { @@ -72,14 +91,17 @@ export class BindenResponse< ); } + if (data instanceof Readable) { + return this.#pipe(data); + } + + // Settles once the response has finished, or rejects when it is closed + // before `end()` (e.g. the client went away) + const done = this.#finished(() => this.#ended); + if (typeof data === "undefined" || data === "") { - return new Promise((resolve) => { - this.end(resolve); - }); - } else if (data instanceof Readable) { - return new Promise((resolve, reject) => { - data.once("error", reject).once("end", resolve).pipe(this); - }); + this.end(); + return done; } if ( @@ -90,18 +112,14 @@ export class BindenResponse< } if (typeof data === "string" && encoding) { - return new Promise((resolve) => { - this.end(data, encoding, resolve); - }); + this.end(data, encoding); } else if (data instanceof Buffer || typeof data === "string") { - return new Promise((resolve) => { - this.end(data, resolve); - }); + this.end(data); + } else { + this.end(data.toString()); } - return new Promise((resolve) => { - this.end(data.toString(), resolve); - }); + return done; } /** Send response as `application/json` */ @@ -130,6 +148,90 @@ export class BindenResponse< } /** Send a file */ + /** Pipes a `Readable` into the response and settles like `#finished()` */ + #pipe(data: Readable): Promise { + // The client has the whole response (and may close the connection) once it + // has the `Content-Length` bytes (none for `HEAD`), maybe before the source + // ends. The header is read at the close: compression removes it. + let received = 0; + const done = this.#finished( + () => + this.#ended || + this.req.method === "HEAD" || + received >= Number(this.getHeader("Content-Length")), + ); + return new Promise((resolve, reject) => { + this.once("close", () => { + // Releases the source (e.g. the file of `sendFile()`) + if (!data.readableEnded) { + data.destroy(); + } + }); + data + .on("data", (chunk: Uint8Array | string) => { + received += Buffer.byteLength(chunk); + }) + .once("error", reject) + .pipe(this); + done.then(resolve, (error: Error) => { + data.destroy(); + reject(error); + }); + }); + } + + /** + * Resolves when the response has finished, rejects when it is closed (e.g. the + * client went away) before it is `complete()` for the client: `end()` has + * handed it over to the connection (`#ended`), or the client has its body + */ + #finished(complete: () => boolean): Promise { + return new Promise((resolve, reject) => { + const premature = (): Error => + Object.assign(new Error("Premature close"), { + code: "ERR_STREAM_PREMATURE_CLOSE", + }); + if (this.destroyed) { + reject(premature()); + return; + } + const listeners: [string, (error?: Error) => void][] = []; + const settle = (error: Error | null): void => { + for (const [event, listener] of listeners) { + this.off(event, listener); + } + if (error) { + reject(error); + } else { + resolve(); + } + }; + listeners.push( + [ + "finish", + (): void => { + settle(null); + }, + ], + [ + "close", + (): void => { + settle(complete() ? null : premature()); + }, + ], + [ + "error", + (error?: Error): void => { + settle(error ?? premature()); + }, + ], + ); + for (const [event, listener] of listeners) { + this.once(event, listener); + } + }); + } + public async sendFile( path: URL | string, options?: Pick, diff --git a/packages/binden/test/response.test.ts b/packages/binden/test/response.test.ts index 2489bae2..4e8e73ff 100644 --- a/packages/binden/test/response.test.ts +++ b/packages/binden/test/response.test.ts @@ -2,11 +2,13 @@ import { deepStrictEqual, ok, throws, rejects } from "node:assert"; import { randomUUID } from "node:crypto"; import { writeFile, rm, mkdir, rmdir, stat } from "node:fs/promises"; -import { IncomingMessage, Server, createServer } from "node:http"; +import { IncomingMessage, Server, createServer, get } from "node:http"; +import { Socket } from "node:net"; import { tmpdir } from "node:os"; import fastJSON from "fast-json-stringify"; import fetch from "node-fetch"; import type { Stats } from "node:fs"; +import { Readable } from "node:stream"; import { afterEach, beforeEach, suite, test } from "node:test"; import { @@ -22,6 +24,28 @@ import { const port = 8080; const url = `http://localhost:${port}`; +/** Requests `url` and goes away after the first chunk of the response body */ +function abortAfterFirstChunk(): Promise { + return new Promise((resolve) => { + get(url, (response) => { + response.once("data", () => { + response.destroy(); + resolve(); + }); + }).on("error", () => { + // The request is aborted on purpose + }); + }); +} + +/** The error code of a settled `send()` (`null` when it has resolved) */ +function settle(promise: Promise): Promise { + return promise.then( + () => null, + (error: unknown) => (error as { code?: unknown }).code, + ); +} + suite("BindenResponse", () => { const filePath = `${tmpdir()}/__binden.test.file`; const dirPath = `${tmpdir()}/__binden.test.dir`; @@ -219,6 +243,152 @@ suite("BindenResponse", () => { deepStrictEqual(data, buffer); }); + test( + ".send() (rejects when the response has been destroyed)", + { timeout: 10_000 }, + async () => { + const settled = new Promise((resolve) => { + server.once("request", (_request, response) => { + response.destroy(); + resolve(settle(response.send("data"))); + }); + }); + get(url).on("error", () => { + // The response is destroyed on purpose + }); + + deepStrictEqual(await settled, "ERR_STREAM_PREMATURE_CLOSE"); + }, + ); + + test(".send() (settles with the events of the response)", async () => { + // Without a connection, `finish` never comes: the events are emitted here + const create = (): BindenResponse => + new BindenResponse(new IncomingMessage(new Socket())); + + // The connection may close before `finish` once `end()` has been called + const ended = create(); + const sent = ended.send("data"); + ended.emit("close"); + deepStrictEqual(await settle(sent), null); + + const failed = create(); + const failing = failed.send("data"); + failed.emit( + "error", + Object.assign(new Error("Failed"), { code: "EFAILED" }), + ); + deepStrictEqual(await settle(failing), "EFAILED"); + + const unknown = create(); + const unknownSent = unknown.send("data"); + unknown.emit("error"); + deepStrictEqual(await settle(unknownSent), "ERR_STREAM_PREMATURE_CLOSE"); + deepStrictEqual(unknown.listenerCount("close"), 0); + }); + + test( + ".send() (resolves once the client has the whole `Content-Length`)", + { timeout: 10_000 }, + async () => { + // The source ends only after the client has the body (and has closed + // the connection): that close is not premature + const stream = new Readable({ + read(): void { + // The data is pushed below + }, + }); + stream.push("data"); + const timer = setTimeout(() => { + stream.push(null); + }, 500); + const settled = new Promise((resolve) => { + server.once("request", (_request, response) => { + response.setHeader("Content-Length", 4); + resolve(settle(response.send(stream))); + }); + }); + const body = await new Promise((resolve) => { + get(url, (response) => { + let text = ""; + response + .setEncoding("utf8") + .on("data", (chunk: string) => { + text += chunk; + }) + .once("end", () => { + resolve(text); + }); + }); + }); + + deepStrictEqual(body, "data"); + deepStrictEqual(await settled, null); + ok(stream.destroyed); + clearTimeout(timer); + }, + ); + + test(".send() (a `Readable` without its whole `Content-Length`)", async () => { + // Without a connection: the events are emitted here + const create = (method: string): BindenResponse => + new BindenResponse( + Object.assign(new IncomingMessage(new Socket()), { method }), + ); + const source = (): Readable => { + const stream = new Readable({ + read(): void { + // The data is pushed below + }, + }); + stream.push("data"); + return stream; + }; + + const partial = create("GET"); + partial.setHeader("Content-Length", 8); + const sent = partial.send(source()); + await new Promise((resolve) => { + setImmediate(resolve); + }); + partial.emit("close"); + deepStrictEqual(await settle(sent), "ERR_STREAM_PREMATURE_CLOSE"); + + // A `HEAD` response has no body + const head = create("HEAD"); + head.setHeader("Content-Length", 8); + const headSent = head.send(source()); + head.emit("close"); + deepStrictEqual(await settle(headSent), null); + }); + + test( + ".send() (destroys a `Readable` when the client goes away)", + { timeout: 10_000 }, + async () => { + const chunk = Buffer.alloc(64 * 1024); + // A long source (256 MiB): the client goes away long before its end. + // Bounded, so that a source that is not destroyed fails the test instead + // of keeping it busy forever. + let left = 4096; + const stream = new Readable({ + read(): void { + left -= 1; + this.push(left > 0 ? chunk : null); + }, + }); + const settled = new Promise((resolve) => { + server.once("request", (_request, response) => { + resolve(settle(response.send(stream))); + }); + }); + await abortAfterFirstChunk(); + + deepStrictEqual(await settled, "ERR_STREAM_PREMATURE_CLOSE"); + ok(stream.destroyed && !stream.readableEnded); + }, + ); + test(".send() (number)", async () => { const number = 1; const serverPromise = new Promise((resolve, reject) => { @@ -549,21 +719,28 @@ suite("BindenResponse", () => { }); }); const headers = { Range: `bytes= - ${end} ` }; - const response = await fetch(`http://localhost:${port + 1}`, { headers }); - await serverPromise; - const data = Buffer.from(await response.arrayBuffer()); - deepStrictEqual(response.headers.get("Content-Range"), null); - deepStrictEqual(response.status, 200); - deepStrictEqual(data, msg.subarray(msg.byteLength - end)); - await new Promise((resolve, reject) => { - newServer.close((error) => { - if (error) { - reject(error); - } else { - resolve(); - } + try { + const response = await fetch(`http://localhost:${port + 1}`, { + headers, }); - }); + await serverPromise; + const data = Buffer.from(await response.arrayBuffer()); + deepStrictEqual(response.headers.get("Content-Range"), null); + deepStrictEqual(response.status, 200); + deepStrictEqual(data, msg.subarray(msg.byteLength - end)); + } finally { + // Also on a failure: a server left open keeps the test process alive + newServer.closeAllConnections(); + await new Promise((resolve, reject) => { + newServer.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } }); test(".sendFile() (with `URL`)", async () => { @@ -615,6 +792,7 @@ suite("BindenResponse", () => { afterEach(async () => { await rmdir(dirPath); await rm(filePath); + server.closeAllConnections(); await new Promise((resolve, reject) => { server.close((error) => { if (error) { diff --git a/packages/compression/index.ts b/packages/compression/index.ts index abe604b3..e9a8eab8 100644 --- a/packages/compression/index.ts +++ b/packages/compression/index.ts @@ -132,7 +132,7 @@ export class Compression extends Middleware { get: () => ended, }); - response.end = ((...args: unknown[]): typeof response => { + response.end = (...args: unknown[]): typeof response => { const callback = args.find((arg) => typeof arg === "function"); if (typeof callback === "function") { response.once("finish", () => { @@ -151,7 +151,7 @@ export class Compression extends Middleware { } } return response; - }) as typeof response.end; + }; } static #getFormat( From 24d65357d79f7b209a8cc90a55cac9a9e625e715 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 21:34:47 +0300 Subject: [PATCH 036/675] fix: call end() callbacks like Node.js in @binden/compression The replaced `end()` attached its callback to `finish`, so a callback passed once the response had already finished was never called, while Node.js calls it with `ERR_STREAM_ALREADY_FINISHED`. It is now passed to the original `end()` in that case. The test of a client going away now requires `send()` to reject (with `ERR_STREAM_PREMATURE_CLOSE`) instead of only not resolving, which a pending promise passed. Co-Authored-By: Claude Opus 5.5 --- packages/compression/index.ts | 15 ++++-- packages/compression/test/compression.test.ts | 52 ++++++++++++------- 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/packages/compression/index.ts b/packages/compression/index.ts index e9a8eab8..96cc5955 100644 --- a/packages/compression/index.ts +++ b/packages/compression/index.ts @@ -125,8 +125,9 @@ export class Compression extends Middleware { }) as typeof response.write; // As for an uncompressed response, `writableEnded` is set by `end()`, the - // `end()` callback runs once the response has finished, and a second - // `end()` does nothing. + // `end()` callback runs once the response has finished (or, when it has + // already finished, with `ERR_STREAM_ALREADY_FINISHED`), and a second + // `end()` writes nothing. Object.defineProperty(response, "writableEnded", { configurable: true, get: () => ended, @@ -135,9 +136,13 @@ export class Compression extends Middleware { response.end = (...args: unknown[]): typeof response => { const callback = args.find((arg) => typeof arg === "function"); if (typeof callback === "function") { - response.once("finish", () => { - (callback as () => void)(); - }); + if (response.writableFinished) { + end(callback); + } else { + response.once("finish", () => { + (callback as () => void)(); + }); + } } if (!ended) { ended = true; diff --git a/packages/compression/test/compression.test.ts b/packages/compression/test/compression.test.ts index 7f17d37a..4d927bc2 100644 --- a/packages/compression/test/compression.test.ts +++ b/packages/compression/test/compression.test.ts @@ -686,32 +686,48 @@ describe("Compression", () => { }); it( - "does not resolve `send()` when the client goes away", + "rejects `send()` when the client goes away", { timeout: 10_000 }, async () => { - let resolved = false; - let closed: Promise = Promise.resolve(); - app.use(new Compression({ format: "gzip" }), (ct: Context) => { - closed = once(ct.response, "close"); - ct.send(randomBytes(4 * 1024 * 1024)).then( - () => { - resolved = true; - }, - () => { - // A rejection is fine as well - }, - ); - ct.done = true; + let settle: (code: unknown) => void; + const settled = new Promise((resolve) => { + settle = resolve; + }); + app.use(new Compression({ format: "gzip" }), async (ct: Context) => { + try { + await ct.send(randomBytes(16 * 1024 * 1024)); + settle(null); + } catch (error: unknown) { + settle((error as { code?: unknown }).code); + } }); const response = await request(url); response.body.destroy(); - await closed; - await new Promise((resolve) => { - setTimeout(resolve, 50); + + deepStrictEqual(await settled, "ERR_STREAM_PREMATURE_CLOSE"); + }, + ); + + it( + "calls an `end()` callback after the response has finished", + { timeout: 5000 }, + async () => { + const code = new Promise((resolve) => { + app.use(new Compression({ format: "gzip" }), async (ct: Context) => { + await ct.send("ok"); + // As Node.js, with `ERR_STREAM_ALREADY_FINISHED` + ct.response.end((error?: (Error & { code?: unknown }) | null) => { + resolve(error?.code); + }); + }); }); - ok(!resolved); + const response = await request(url); + const body = await response.body.arrayBuffer(); + + deepStrictEqual((await gunzip(Buffer.from(body))).toString(), "ok"); + deepStrictEqual(await code, "ERR_STREAM_ALREADY_FINISHED"); }, ); From 0847d8cb519889f08b1f37ea5276be7eac4b5050 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 21:48:15 +0300 Subject: [PATCH 037/675] fix: never throw from the default error serializer of binden The error serializer read `error.constructor.name` directly, so an error without a constructor (or with a throwing getter) made it throw while the error handler logged, and with debug logs enabled the request crashed the process with an unhandled rejection. Every property is now read through a guard, the type falls back to the `Object.prototype.toString()` tag, and only plain values are kept: `status` when it is an HTTP status, `expose` when it is a boolean, and `code` only when it is a Node.js error code (`E...` or `ERR_...`), since any other `code` may come from the input. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 2 +- packages/binden/src/logger.ts | 51 +++++++++++++++---- packages/binden/test/binden.test.ts | 67 ++++++++++++++++++++++++- packages/binden/test/logger.test.ts | 78 +++++++++++++++++++++++++++-- 4 files changed, 180 insertions(+), 18 deletions(-) diff --git a/packages/binden/README.md b/packages/binden/README.md index 90c1cdce..dca7b222 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -60,7 +60,7 @@ Every context logs through `context.log`: a child (with the `trace_id` of the co - the `Authorization`, `Cookie`, `Proxy-Authorization` and `Set-Cookie` headers are redacted, and the query string is dropped from the request URL; - a logged `body` (e.g. the one that [`@binden/body`](https://github.com/binden-js/binden/tree/main/packages/body) failed to parse) is redacted; -- an `error` is reduced to its `type`, `status`, `expose` and `code`: its message, stack, cause and `json` may quote the input (e.g. an error of `JSON.parse()`). +- an `error` is reduced to its `type`, `status` and `expose`, and to its `code` when it is a Node.js error code (such as `ECONNRESET` or `ERR_STREAM_PREMATURE_CLOSE`): its message, stack, cause and `json` may quote the input (e.g. an error of `JSON.parse()`). The serializer never throws, whatever is thrown. A custom logger (for example with another level or destination, or to log more details) is passed with the `logger` option. The default `serializers` are exported to build on: diff --git a/packages/binden/src/logger.ts b/packages/binden/src/logger.ts index d573a231..6fea1102 100644 --- a/packages/binden/src/logger.ts +++ b/packages/binden/src/logger.ts @@ -1,3 +1,4 @@ +import { STATUS_CODES } from "node:http"; import Pino from "pino"; import { Logger } from "@binden/logger"; import { Context } from "./context.js"; @@ -21,27 +22,57 @@ function redact(headers: Record): Record { ); } +/** Node.js error codes: system (`ECONNRESET`) and internal (`ERR_STREAM_DESTROYED`) ones */ +const nodeErrorCode = /^(?:E[A-Z0-9]+|ERR_[A-Z0-9_]+)$/u; + +/** Reads a property, so that a throwing getter (or proxy) cannot break the logs */ +function get(value: object, key: string): unknown { + try { + return Reflect.get(value, key) as unknown; + } catch { + return null; + } +} + +/** The name of the constructor (as pino), otherwise the `Object.prototype.toString()` tag */ +function typeOf(value: object): string { + const constructor = get(value, "constructor"); + const name = + typeof constructor === "function" ? get(constructor, "name") : null; + if (typeof name === "string" && name !== "") { + return name; + } + try { + return Object.prototype.toString.call(value).slice(8, -1); + } catch { + return typeof value; + } +} + /** * The serializers of the default logger, which keep credentials, query strings, * request bodies and error details (messages, stacks, causes and `json`, which * may quote the input, e.g. the one of `JSON.parse()`) out of the logs */ export const serializers = { - // Only the kind of an error: its type, `status`, `expose` and `code` + // Only the kind of an error: its type, `status`, `expose` and a Node.js + // `code`. Never throws, whatever is thrown. error: (error: unknown): Record => { - if (!(error instanceof Error)) { + if (error === null) { + return { type: "null" }; + } else if (typeof error !== "object" && typeof error !== "function") { return { type: typeof error }; } - const { status, expose, code } = error as { - status?: unknown; - expose?: unknown; - code?: unknown; - }; + const status = get(error, "status"); + const expose = get(error, "expose"); + const code = get(error, "code"); return { - type: error.constructor.name, - ...(typeof status === "number" ? { status } : {}), + type: typeOf(error), + ...(typeof status === "number" && typeof STATUS_CODES[status] === "string" + ? { status } + : {}), ...(typeof expose === "boolean" ? { expose } : {}), - ...(typeof code === "string" || typeof code === "number" ? { code } : {}), + ...(typeof code === "string" && nodeErrorCode.test(code) ? { code } : {}), }; }, body: (): string => redacted, diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index 5377032a..71347985 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -1,11 +1,11 @@ /* eslint-disable init-declarations, class-methods-use-this, @typescript-eslint/only-throw-error */ -import { deepStrictEqual, throws } from "node:assert"; +import { deepStrictEqual, ok, throws } from "node:assert"; import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import { Server } from "node:http"; import { Agent } from "node:https"; import { afterEach, beforeEach, suite, test } from "node:test"; -import { Logger } from "@binden/logger"; +import { Logger, type LoggerOptions } from "@binden/logger"; import fetch from "node-fetch"; import { @@ -13,6 +13,7 @@ import { Binden, BindenError, type IBindenErrorOptions, + type IBindenOptions, Middleware, type IMiddlewareParams, Context, @@ -60,6 +61,49 @@ class ErrorMiddleware extends CustomMiddleware { const port = 8080; const url = `http://localhost:${port}`; +interface ILoggedApp { + app: Binden; + url: string; + /** The log lines written so far */ + output(): string; + close(): Promise; +} + +/** An application (on `port + 1`) whose debug logs are captured */ +async function loggedApp( + options: IBindenOptions = {}, + logSerializers: LoggerOptions["serializers"] = serializers, +): Promise { + const lines: string[] = []; + const destination = { + write(line: string): void { + lines.push(line); + }, + }; + const logger = new Logger({ + destination, + level: "debug", + serializers: logSerializers, + }); + const logged = new Binden({ ...options, logger }); + const loggedServer = logged.createServer(); + const loggedUrl = `http://localhost:${port + 1}`; + await new Promise((resolve) => { + loggedServer.listen(port + 1, resolve); + }); + return { + app: logged, + url: loggedUrl, + output: (): string => lines.join(""), + close: async (): Promise => { + loggedServer.closeAllConnections(); + await new Promise((resolve) => { + loggedServer.close(resolve); + }); + }, + }; +} + suite("Binden", () => { let app: Binden; let server: Server; @@ -250,6 +294,25 @@ suite("Binden", () => { } }); + test("logs an error without a constructor (debug)", async () => { + const logged = await loggedApp(); + try { + logged.app.use(() => { + throw Object.assign(new Error("secret-message"), { constructor: null }); + }); + + // A deadline: a crashing error handler would never reply + const response = await fetch(logged.url, { + signal: AbortSignal.timeout(5000), + }); + + deepStrictEqual(response.status, DefaultErrorCode); + ok(logged.output().includes(`"error":{"type":"Error"}`), logged.output()); + } finally { + await logged.close(); + } + }); + test("Disabled middleware", async () => { const m1 = new SendMiddleware({ disabled: true }); const m2 = new SendMiddleware(); diff --git a/packages/binden/test/logger.test.ts b/packages/binden/test/logger.test.ts index 8e7dfbe2..57a38c83 100644 --- a/packages/binden/test/logger.test.ts +++ b/packages/binden/test/logger.test.ts @@ -107,17 +107,85 @@ suite("serializers", () => { code: "ECONNRESET", }); - const numeric = Object.assign(new TypeError("secret-message"), { + const internal = Object.assign(new Error("secret-message"), { + code: "ERR_STREAM_PREMATURE_CLOSE", + }); + deepStrictEqual(serializers.error(internal), { + type: "Error", + code: "ERR_STREAM_PREMATURE_CLOSE", + }); + + const invalid = Object.assign(new TypeError("secret-message"), { code: 42, status: "400", expose: "true", }); - deepStrictEqual(serializers.error(numeric), { - type: "TypeError", - code: 42, + deepStrictEqual(serializers.error(invalid), { type: "TypeError" }); + + // Only Node.js error codes: any other `code` may come from the input + for (const code of [ + "secret-code", + "SECRET-CODE", + "Esecret", + "ERR_secret", + ]) { + deepStrictEqual(serializers.error(Object.assign(new Error(), { code })), { + type: "Error", + }); + } + + deepStrictEqual( + serializers.error({ status: 400, expose: true, message: "secret" }), + { type: "Object", status: 400, expose: true }, + ); + deepStrictEqual(serializers.error({ status: 999 }), { type: "Object" }); + deepStrictEqual(serializers.error("secret-message"), { type: "string" }); + deepStrictEqual(serializers.error(null), { type: "null" }); + }); + + test("error: never throws", () => { + const anonymous = Object.assign(new Error("secret-message"), { + constructor: null, }); + deepStrictEqual(serializers.error(anonymous), { type: "Error" }); - deepStrictEqual(serializers.error("secret-message"), { type: "string" }); + // An anonymous class + deepStrictEqual(serializers.error(new (class extends Error {})()), { + type: "Error", + }); + + const getters = new Error("secret-message"); + for (const key of ["constructor", "status", "expose", "code"]) { + Object.defineProperty(getters, key, { + get(): never { + throw new Error("getter"); + }, + }); + } + deepStrictEqual(serializers.error(getters), { type: "Error" }); + + const prototypeless = Object.assign(Object.create(null) as object, { + status: 404, + }); + deepStrictEqual(serializers.error(prototypeless), { + type: "Object", + status: 404, + }); + + const { proxy, revoke } = Proxy.revocable(new Error("secret-message"), {}); + revoke(); + deepStrictEqual(serializers.error(proxy), { type: "object" }); + + const thrower = (): never => { + throw new Error("trap"); + }; + const traps = new Proxy(new Error("secret-message"), { + get: thrower, + getPrototypeOf: thrower, + }); + deepStrictEqual(serializers.error(traps), { type: "object" }); + + deepStrictEqual(serializers.error(thrower), { type: "Function" }); }); test("body", () => { From 635bbde3d5b7c4ce82972dc6a1b12de517e0d361 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 21:51:21 +0300 Subject: [PATCH 038/675] fix: keep the fallback error logs of binden safe When `JSON.stringify()` failed on the `json` of an exposed error (e.g. a `bigint`), the warning logged that `json` as a separate field, which no serializer redacted: a password in it reached the logs in clear text; a `message` that is not a string was logged the same way. These logs now carry only the context and the error, like the others. Logging no longer breaks the handling of a request: the framework logs through a guard, so a failing (custom) logger or serializer cannot prevent the error response, and a failing error handler (e.g. a custom `error_handler` that throws) destroys the connection instead of crashing the process with an unhandled rejection. Each case is covered by an HTTP test. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/binden.ts | 53 ++++++++++--- packages/binden/test/binden.test.ts | 111 +++++++++++++++++++++++++++- 2 files changed, 151 insertions(+), 13 deletions(-) diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index d62f16df..e140df1e 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -17,6 +17,8 @@ export type IStack = [IStackItem[], RegExp | string | null]; export const DefaultErrorCode = 500; +type ILogLevel = "debug" | "error" | "trace" | "warn"; + export interface IBindenOptions { auto_head?: boolean; error_handler?: (context: Context, error: unknown) => void; @@ -180,9 +182,17 @@ export class Binden { response: BindenResponse, ): void { const context = new Context({ request, response, logger: this.#logger }); - this.#handle(context).catch((error: unknown) => { - this.#errorHandler(context, error); - }); + this.#handle(context) + .catch((error: unknown) => { + this.#errorHandler(context, error); + }) + .catch((error: unknown) => { + // The error handler failed (e.g. a custom `error_handler` threw): the + // response cannot be completed, but the process must not crash + const extra = { context, error }; + Binden.#log(context, "error", "The error handler failed", extra); + request.destroy(); + }); } async #handle(context: Context): Promise { @@ -236,18 +246,22 @@ export class Binden { /** Default error hanlder (uses the provided `error_handler`, if any) */ #errorHandler(context: Context, error: unknown): void { - const { response, request, log } = context; + const { response, request } = context; + // Only the context and the error, whose serializers keep the input out of the logs const base = { context, error }; + const log = (level: ILogLevel, message: string): void => { + Binden.#log(context, level, message, base); + }; if (this.#error_handler) { - log.trace("Passing error to the provided `error_handler`", base); + log("trace", "Passing error to the provided `error_handler`"); this.#error_handler(context, error); } else if (response.writableEnded) { - log.debug("`.end()` has been called. Destroying the socket", base); + log("debug", "`.end()` has been called. Destroying the socket"); request.destroy(); } else if (response.headersSent) { - log.debug("Headers has been sent. Ending the response", base); + log("debug", "Headers has been sent. Ending the response"); response.end(); } else { @@ -263,7 +277,7 @@ export class Binden { const expose = Boolean((error as { expose?: unknown }).expose); if (!expose) { - log.debug("Error should not be exposed", base); + log("debug", "Error should not be exposed"); response.end(); return; } @@ -275,7 +289,7 @@ export class Binden { const message = JSON.stringify(json); response.setHeader("Content-Type", ct_json).end(message); } catch { - log.warn("`JSON.stringify` failed", { ...base, json }); + log("warn", "`JSON.stringify` failed"); response.end(); } return; @@ -284,7 +298,7 @@ export class Binden { const { message } = error as { message?: unknown }; if (typeof message !== "string" || !message) { - log.debug("`message` is not a valid string", { ...base, message }); + log("debug", "`message` is not a valid string"); response.end(); return; } else if (typeof response.getHeader("Content-Type") === "undefined") { @@ -302,7 +316,7 @@ export class Binden { const { name } = middleware.constructor; if (middleware instanceof Middleware && middleware.disabled) { - context.log.debug("Middleware is disabled", { name }); + Binden.#log(context, "debug", "Middleware is disabled", { name }); return context; } @@ -314,7 +328,8 @@ export class Binden { return next instanceof Context ? next : context; } catch (error: unknown) { if (middleware instanceof Middleware && middleware.ignore_errors) { - context.log.debug("Middleware ignores errors", { error, name }); + const extra = { error, name }; + Binden.#log(context, "debug", "Middleware ignores errors", extra); return context; } @@ -322,6 +337,20 @@ export class Binden { } } + /** Framework logs never break the handling of a request (e.g. a failing serializer) */ + static #log( + context: Context, + level: ILogLevel, + message: string, + extra: Record, + ): void { + try { + context.log[level](message, extra); + } catch { + // Nothing else to report it to + } + } + static #validateItem(item: unknown): item is IStackItem { return ( item instanceof Middleware || diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index 71347985..8a8c5933 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -1,5 +1,5 @@ /* eslint-disable init-declarations, class-methods-use-this, @typescript-eslint/only-throw-error */ -import { deepStrictEqual, ok, throws } from "node:assert"; +import { deepStrictEqual, ok, rejects, throws } from "node:assert"; import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import { Server } from "node:http"; @@ -313,6 +313,115 @@ suite("Binden", () => { } }); + test("does not log a `json` that cannot be stringified", async () => { + const logged = await loggedApp(); + try { + logged.app.use(() => { + throw new BindenError(400, { + expose: true, + json: { password: "SECRET", amount: 1n }, + }); + }); + + const response = await fetch(logged.url, { + signal: AbortSignal.timeout(5000), + }); + + deepStrictEqual(response.status, 400); + deepStrictEqual(await response.text(), ""); + ok(logged.output().includes("`JSON.stringify` failed")); + ok(!logged.output().includes("SECRET"), logged.output()); + } finally { + await logged.close(); + } + }); + + test("does not log a `message` that is not a string", async () => { + const logged = await loggedApp(); + try { + logged.app.use(() => { + throw { status: 400, expose: true, message: { password: "SECRET" } }; + }); + + const response = await fetch(logged.url, { + signal: AbortSignal.timeout(5000), + }); + + deepStrictEqual(response.status, 400); + deepStrictEqual(await response.text(), ""); + ok(logged.output().includes("`message` is not a valid string")); + ok(!logged.output().includes("SECRET"), logged.output()); + } finally { + await logged.close(); + } + }); + + test("does not log a `code` that is not a Node.js error code", async () => { + const logged = await loggedApp(); + try { + logged.app.use(() => { + throw Object.assign(new Error("Failed"), { code: "SECRET-CODE" }); + }); + + const response = await fetch(logged.url, { + signal: AbortSignal.timeout(5000), + }); + + deepStrictEqual(response.status, DefaultErrorCode); + ok(logged.output().includes(`"error":{"type":"Error"}`), logged.output()); + ok(!logged.output().includes("SECRET"), logged.output()); + } finally { + await logged.close(); + } + }); + + test("replies when the logger fails", async () => { + const logged = await loggedApp( + {}, + { + ...serializers, + error: (): never => { + throw new Error("A failing serializer"); + }, + }, + ); + try { + logged.app.use(() => { + throw new BindenError(401); + }); + + const response = await fetch(logged.url, { + signal: AbortSignal.timeout(5000), + }); + + deepStrictEqual(response.status, 401); + } finally { + await logged.close(); + } + }); + + test("closes the connection when the error handler fails", async () => { + const logged = await loggedApp({ + error_handler: (): never => { + throw new Error("A failing error handler"); + }, + }); + try { + logged.app.use(() => { + throw new BindenError(400); + }); + + // The connection is closed (not left open until the deadline) + await rejects( + fetch(logged.url, { signal: AbortSignal.timeout(5000) }), + (error: Error) => error.name !== "AbortError", + ); + ok(logged.output().includes("The error handler failed"), logged.output()); + } finally { + await logged.close(); + } + }); + test("Disabled middleware", async () => { const m1 = new SendMiddleware({ disabled: true }); const m2 = new SendMiddleware(); From 9948b0f0ebb71b9f564e23d4d8318dd9f0327e75 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 21:53:33 +0300 Subject: [PATCH 039/675] ci: publish releases without a release commit `@semantic-release/git` pushed a release commit (versions and `CHANGELOG.md`) to `main` with `GITHUB_TOKEN`, which the planned required status checks would reject: only an account allowed to bypass the branch protection could push it. semantic-release recommends against committing during a release, and the GitHub Releases already carry the notes. Releases now only tag, publish the workspaces and create the GitHub Release: `@semantic-release/git` and `@semantic-release/changelog` are removed, and so are the GPG key and the committer of the release job. The versions in the repository stay at `0.0.0-development` (`release.mjs version` sets the published one in the release checkout), and the root `CHANGELOG.md` points to the GitHub Releases and to the changelogs of the former packages. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 27 +------- .prettierignore | 2 - .releaserc.json | 19 +----- CHANGELOG.md | 12 ++++ README.md | 12 +++- package-lock.json | 106 +++--------------------------- package.json | 2 - packages/binden/package.json | 4 +- packages/body/package.json | 6 +- packages/compression/package.json | 4 +- packages/logger/package.json | 2 +- packages/multipart/package.json | 4 +- scripts/release.mjs | 2 +- 13 files changed, 47 insertions(+), 155 deletions(-) delete mode 100644 .prettierignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97b8f7a0..8e50f3fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,39 +128,18 @@ jobs: check-latest: true registry-url: https://registry.npmjs.org - - name: Import GPG key - run: printf "%s" "$GPG_KEY" | base64 --decode | gpg --batch --import - env: - GPG_KEY: ${{ secrets.GPG_KEY }} - - - name: Add the custom gpg executable - # The passphrase is read from the environment of the release step, quoted, so - # that it is passed as a single argument even if it contains spaces. - run: | - printf "%s\n" "#!/bin/sh" 'exec gpg --batch --pinentry-mode=loopback --passphrase "$GPG_KEY_PASSPHRASE" "$@"' > /tmp/gpg.sh - chmod +x /tmp/gpg.sh - - - name: Setup git - run: | - git config commit.gpgsign true - git config user.signingkey "$GPG_KEY_ID" - git config gpg.program /tmp/gpg.sh - env: - GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }} - - name: Install dependencies and build the workspaces run: npm ci - # Publishes every workspace with the same version (see scripts/release.mjs). + # Tags the release, publishes every workspace with the same version (see + # scripts/release.mjs) and creates the GitHub Release, whose notes are the changelog. + # Nothing is committed to `main`, so its branch protection applies as usual. # npm uses trusted publishing (OIDC) when it is configured for a package and # falls back to NODE_AUTH_TOKEN otherwise. - name: Release run: npx semantic-release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GIT_COMMITTER_EMAIL: ${{ secrets.GIT_COMMITTER_EMAIL }} - GIT_COMMITTER_NAME: ${{ secrets.GIT_COMMITTER_NAME }} - GPG_KEY_PASSPHRASE: ${{ secrets.GPG_KEY_PASSPHRASE }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: true diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 7979d0d2..00000000 --- a/.prettierignore +++ /dev/null @@ -1,2 +0,0 @@ -# Generated by semantic-release (other ignores come from .gitignore) -/CHANGELOG.md diff --git a/.releaserc.json b/.releaserc.json index 70425f2f..13934d1f 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -135,12 +135,6 @@ } } ], - [ - "@semantic-release/changelog", - { - "changelogTitle": "# Changelog" - } - ], [ "@semantic-release/exec", { @@ -149,17 +143,6 @@ "publishCmd": "node scripts/release.mjs publish" } ], - "@semantic-release/github", - [ - "@semantic-release/git", - { - "assets": [ - "CHANGELOG.md", - "package-lock.json", - "packages/*/package.json" - ], - "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" - } - ] + "@semantic-release/github" ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 825c32f0..3d23875e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,13 @@ # Changelog + +Since 4.0.0, all packages of this repository (`binden`, `@binden/logger`, `@binden/body`, +`@binden/compression` and `@binden/multipart`) are released together with one version. The notes of +every release are published as [GitHub Releases](https://github.com/binden-js/binden/releases). + +The history of the earlier versions is kept in the changelog of each package: + +- [`binden`](packages/binden/CHANGELOG.md) +- [`@binden/logger`](packages/logger/CHANGELOG.md) +- [`@binden/body`](packages/body/CHANGELOG.md) +- [`@binden/compression`](packages/compression/CHANGELOG.md) +- [`@binden/multipart`](packages/multipart/CHANGELOG.md) diff --git a/README.md b/README.md index 7e172268..2d17337a 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,18 @@ on every push to `main` (see [`.releaserc.json`](.releaserc.json)): last `v*` tag; - [`scripts/release.mjs`](scripts/release.mjs) sets that version in **every** workspace (and the internal dependency ranges to `^`) and publishes all packages to npm in dependency order; -- the release commit updates [`CHANGELOG.md`](CHANGELOG.md) for all packages. +- the `v` tag and a [GitHub Release](https://github.com/binden-js/binden/releases) with the + release notes are created: they are the changelog (see [`CHANGELOG.md`](CHANGELOG.md)). + +Nothing is committed back to `main`, as +[recommended by semantic-release](https://semantic-release.gitbook.io/semantic-release/support/faq): +a release commit would have to bypass the branch protection of `main`. The versions in the +repository therefore stay at `0.0.0-development` (and the internal ranges at `^0.0.0-development`); +the published version is set only in the checkout of the release job. `npm run release:check` verifies that all workspaces share one version. If a release fails after -some packages were published, `node scripts/release.mjs publish --skip-existing` publishes the +some packages were published, run `node scripts/release.mjs version ` and then +`node scripts/release.mjs publish --skip-existing` (without committing the result) to publish the rest. The `CHANGELOG.md` files inside `packages/*` describe the history before the packages were merged diff --git a/package-lock.json b/package-lock.json index 0d9fff30..c5157b3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,9 +16,7 @@ "@commitlint/cli": "^21.2.3", "@commitlint/config-conventional": "^21.2.3", "@eslint/js": "^10.0.1", - "@semantic-release/changelog": "^7.0.0", "@semantic-release/exec": "^7.1.0", - "@semantic-release/git": "^11.0.1", "@types/node": "^24.19.2", "c8": "^12.0.0", "conventional-changelog-conventionalcommits": "^9.3.1", @@ -1472,24 +1470,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@semantic-release/changelog": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@semantic-release/changelog/-/changelog-7.0.0.tgz", - "integrity": "sha512-TNPyag5db24o7jWjre7UwKB4EcL8oJxbRhnDQ7hmZRAYqzreAc6PgdxQuU3pppp5xQinYtiumL0iG8SSKvnlzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@semantic-release/error": "^4.0.0", - "aggregate-error": "^5.0.0", - "lodash-es": "^4.17.21" - }, - "engines": { - "node": "^22.22.2 || >=24.15" - }, - "peerDependencies": { - "semantic-release": ">=20.1.0" - } - }, "node_modules/@semantic-release/commit-analyzer": { "version": "13.0.1", "resolved": "https://registry.npmjs.org/@semantic-release/commit-analyzer/-/commit-analyzer-13.0.1.tgz", @@ -1631,56 +1611,6 @@ "node": ">=8" } }, - "node_modules/@semantic-release/git": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/@semantic-release/git/-/git-11.0.1.tgz", - "integrity": "sha512-Zr8BUYCTZMc8V6wDKN2dpR7nJgewd9I6THL3ydLTnp3OEdTo1/4RBLNYaeRucYMsjMv+BXoCNfXA0NADj1kwhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@semantic-release/error": "^4.0.0", - "aggregate-error": "^5.0.0", - "debug": "^4.0.0", - "dir-glob": "^3.0.0", - "execa": "^10.0.0", - "lodash-es": "^4.17.21", - "micromatch": "^4.0.0", - "p-reduce": "^3.0.0" - }, - "engines": { - "node": "^22.22.2 || >=24.15" - }, - "peerDependencies": { - "semantic-release": ">=20.1.0" - } - }, - "node_modules/@semantic-release/git/node_modules/execa": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-10.1.1.tgz", - "integrity": "sha512-yJAC9ulsE6fU0K6wzJBIH+wI+KAtMeu6Ck8rs23OVzvEhqXjGADdI6K+YNIDAjcQQ6AwXTD96cKExJo6xFJXyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "figures": "^6.1.0", - "get-stream": "^9.0.1", - "human-signals": "^8.0.1", - "is-plain-obj": "^4.1.0", - "is-stream": "^4.0.1", - "npm-run-path": "^6.0.0", - "pretty-ms": "^9.3.1", - "signal-exit": "^4.1.0", - "strip-final-newline": "^4.0.0", - "which-command": "^0.1.0", - "yoctocolors": "^2.2.0" - }, - "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, "node_modules/@semantic-release/github": { "version": "12.0.10", "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.10.tgz", @@ -9017,22 +8947,6 @@ "node": ">= 8" } }, - "node_modules/which-command": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/which-command/-/which-command-0.1.0.tgz", - "integrity": "sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==", - "dev": true, - "license": "MIT", - "bin": { - "which-command": "cli.js" - }, - "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/sindresorhus/which-command?sponsor=1" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -9243,10 +9157,10 @@ } }, "packages/binden": { - "version": "3.0.0", + "version": "0.0.0-development", "license": "MIT", "dependencies": { - "@binden/logger": "^3.0.0" + "@binden/logger": "^0.0.0-development" }, "devDependencies": { "@types/sinon": "^17.0.3", @@ -9265,10 +9179,10 @@ }, "packages/body": { "name": "@binden/body", - "version": "3.0.0", + "version": "0.0.0-development", "license": "AGPL-3.0-only", "devDependencies": { - "@binden/logger": "^3.0.0", + "@binden/logger": "^0.0.0-development", "ajv": "^8.20.0", "undici": "^7.11.0" }, @@ -9281,12 +9195,12 @@ "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" }, "peerDependencies": { - "binden": "^3.0.0" + "binden": "^0.0.0-development" } }, "packages/compression": { "name": "@binden/compression", - "version": "3.0.0", + "version": "0.0.0-development", "license": "AGPL-3.0-only", "devDependencies": { "undici": "^8.3.0" @@ -9300,7 +9214,7 @@ "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" }, "peerDependencies": { - "binden": "^3.0.0" + "binden": "^0.0.0-development" } }, "packages/compression/node_modules/undici": { @@ -9315,7 +9229,7 @@ }, "packages/logger": { "name": "@binden/logger", - "version": "3.0.0", + "version": "0.0.0-development", "license": "MIT", "dependencies": { "pino": "^10.3.1" @@ -9331,7 +9245,7 @@ }, "packages/multipart": { "name": "@binden/multipart", - "version": "3.0.0", + "version": "0.0.0-development", "license": "AGPL-3.0-only", "dependencies": { "busboy": "^1.6.0" @@ -9349,7 +9263,7 @@ }, "peerDependencies": { "@types/busboy": "^1.5.4", - "binden": "^3.0.0" + "binden": "^0.0.0-development" } }, "packages/multipart/node_modules/undici": { diff --git a/package.json b/package.json index 21461d27..95bfdd25 100644 --- a/package.json +++ b/package.json @@ -40,9 +40,7 @@ "@commitlint/cli": "^21.2.3", "@commitlint/config-conventional": "^21.2.3", "@eslint/js": "^10.0.1", - "@semantic-release/changelog": "^7.0.0", "@semantic-release/exec": "^7.1.0", - "@semantic-release/git": "^11.0.1", "@types/node": "^24.19.2", "c8": "^12.0.0", "conventional-changelog-conventionalcommits": "^9.3.1", diff --git a/packages/binden/package.json b/packages/binden/package.json index 6aaacbd6..5b45eb65 100644 --- a/packages/binden/package.json +++ b/packages/binden/package.json @@ -1,6 +1,6 @@ { "name": "binden", - "version": "3.0.0", + "version": "0.0.0-development", "description": "A simple HTTP server framework", "main": "dist/index.js", "type": "module", @@ -37,7 +37,7 @@ }, "homepage": "https://github.com/binden-js/binden/tree/main/packages/binden#readme", "dependencies": { - "@binden/logger": "^3.0.0" + "@binden/logger": "^0.0.0-development" }, "devDependencies": { "@types/sinon": "^17.0.3", diff --git a/packages/body/package.json b/packages/body/package.json index 31cbb769..944cc77e 100644 --- a/packages/body/package.json +++ b/packages/body/package.json @@ -1,6 +1,6 @@ { "name": "@binden/body", - "version": "3.0.0", + "version": "0.0.0-development", "description": "Binden body parser middleware", "main": "dist/index.js", "type": "module", @@ -36,10 +36,10 @@ }, "homepage": "https://github.com/binden-js/binden/tree/main/packages/body#readme", "peerDependencies": { - "binden": "^3.0.0" + "binden": "^0.0.0-development" }, "devDependencies": { - "@binden/logger": "^3.0.0", + "@binden/logger": "^0.0.0-development", "ajv": "^8.20.0", "undici": "^7.11.0" }, diff --git a/packages/compression/package.json b/packages/compression/package.json index 6d5e3c86..3e265f36 100644 --- a/packages/compression/package.json +++ b/packages/compression/package.json @@ -1,6 +1,6 @@ { "name": "@binden/compression", - "version": "3.0.0", + "version": "0.0.0-development", "description": "binden compression middleware", "main": "dist/index.js", "type": "module", @@ -39,7 +39,7 @@ }, "homepage": "https://github.com/binden-js/binden/tree/main/packages/compression#readme", "peerDependencies": { - "binden": "^3.0.0" + "binden": "^0.0.0-development" }, "devDependencies": { "undici": "^8.3.0" diff --git a/packages/logger/package.json b/packages/logger/package.json index e6efa603..a747be25 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -1,6 +1,6 @@ { "name": "@binden/logger", - "version": "3.0.0", + "version": "0.0.0-development", "description": "A simple logger (based on pino)", "main": "dist/index.js", "type": "module", diff --git a/packages/multipart/package.json b/packages/multipart/package.json index 2d4015f2..e0552384 100644 --- a/packages/multipart/package.json +++ b/packages/multipart/package.json @@ -1,6 +1,6 @@ { "name": "@binden/multipart", - "version": "3.0.0", + "version": "0.0.0-development", "description": "Binden middleware for parsing incoming FormData", "main": "dist/index.js", "type": "module", @@ -41,7 +41,7 @@ }, "peerDependencies": { "@types/busboy": "^1.5.4", - "binden": "^3.0.0" + "binden": "^0.0.0-development" }, "devDependencies": { "undici": "^8.3.0" diff --git a/scripts/release.mjs b/scripts/release.mjs index 796d1009..dc8321b1 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -50,7 +50,7 @@ function errorCode(stdout) { } // Only a confirmed "not found" (E404) means "not published": registry, network or -// authentication errors must stop the release before anything is committed or tagged. +// authentication errors must stop the release before anything is tagged or published. function isPublished(name, version) { try { const stdout = npm(["view", `${name}@${version}`, "version", "--json"], { From 130cb0015052b2267ae7cfc3c202a1001557b05c Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 13 Oct 2026 21:54:12 +0300 Subject: [PATCH 040/675] ci: type check the README examples strictly The README examples were checked with `noImplicitAny: false`, so an untyped callback parameter passed. They are now checked with the same `strict` options as the consumer project, and the two examples with an untyped `context` declare its type. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 9 ++++++--- scripts/consumer-check.mjs | 4 +--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/binden/README.md b/packages/binden/README.md index dca7b222..50dd18d6 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -15,12 +15,13 @@ npm install binden - `.use()` - Add a `Middleware/Router` to the stack ```typescript -import { Binden } from "binden"; +import { Binden, type Context } from "binden"; const app = new Binden().use(middleware1).use(router2); app.use("/path", middleware2, router2); app.use(new RegExp("path"), router3, middleware1); -const middleware3 = (context) => context.json({ message: "Hello World!" }); +const middleware3 = (context: Context): Promise => + context.json({ message: "Hello World!" }); app.use("/path2", middleware3); ``` @@ -137,7 +138,9 @@ class MyMiddleware extends Middleware { or with a function ```typescript -const MyMiddleware = (context): void => { +import type { Context } from "binden"; + +const MyMiddleware = (context: Context): void => { context.done = true; }; ``` diff --git a/scripts/consumer-check.mjs b/scripts/consumer-check.mjs index e246d347..7ca396a4 100644 --- a/scripts/consumer-check.mjs +++ b/scripts/consumer-check.mjs @@ -203,8 +203,7 @@ try { ]); run(process.execPath, [join(dir, "dist/consumer.js")]); - // Each example is a module (`moduleDetection`), and the parameters of their - // callbacks may be untyped (`noImplicitAny`) + // Each example is a module (`moduleDetection`), checked as strictly as the consumer writeFileSync( join(examples, "tsconfig.json"), `${JSON.stringify({ @@ -212,7 +211,6 @@ try { module: "nodenext", target: "es2022", strict: true, - noImplicitAny: false, moduleDetection: "force", noEmit: true, skipLibCheck, From aaafeec6cf01d964f8f0479bd8dd41fc1491ca89 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 00:23:20 +0300 Subject: [PATCH 041/675] dependencies: bump pino from 10.3.1 to 10.4.0 in @binden/logger Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 2 +- packages/logger/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index c5157b3a..2f9d723a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9232,7 +9232,7 @@ "version": "0.0.0-development", "license": "MIT", "dependencies": { - "pino": "^10.3.1" + "pino": "^10.4.0" }, "engines": { "node": ">=24.14.1", diff --git a/packages/logger/package.json b/packages/logger/package.json index a747be25..7a7ac125 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -34,7 +34,7 @@ }, "homepage": "https://github.com/binden-js/binden/tree/main/packages/logger#readme", "dependencies": { - "pino": "^10.3.1" + "pino": "^10.4.0" }, "publishConfig": { "access": "public" From 1a8e83b86dc9941e87e57c6035558273611f8ce5 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 00:23:41 +0300 Subject: [PATCH 042/675] peerDependencies: bump @types/busboy from 1.5.4 to 1.5.5 in @binden/multipart Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 2 +- packages/multipart/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2f9d723a..9cb73a42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9262,7 +9262,7 @@ "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" }, "peerDependencies": { - "@types/busboy": "^1.5.4", + "@types/busboy": "^1.5.5", "binden": "^0.0.0-development" } }, diff --git a/packages/multipart/package.json b/packages/multipart/package.json index e0552384..65db1ef2 100644 --- a/packages/multipart/package.json +++ b/packages/multipart/package.json @@ -40,7 +40,7 @@ "busboy": "^1.6.0" }, "peerDependencies": { - "@types/busboy": "^1.5.4", + "@types/busboy": "^1.5.5", "binden": "^0.0.0-development" }, "devDependencies": { From 990b64e1cf0fef301951725a07a7e05ea756aca9 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 00:24:03 +0300 Subject: [PATCH 043/675] devDependencies: bump fast-json-stringify from 7.0.1 to 7.1.0 Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 2 +- packages/binden/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9cb73a42..11ff8335 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9164,7 +9164,7 @@ }, "devDependencies": { "@types/sinon": "^17.0.3", - "fast-json-stringify": "^7.0.1", + "fast-json-stringify": "^7.1.0", "node-fetch": "^3.3.2", "sinon": "^19.0.2" }, diff --git a/packages/binden/package.json b/packages/binden/package.json index 5b45eb65..8e421cd7 100644 --- a/packages/binden/package.json +++ b/packages/binden/package.json @@ -41,7 +41,7 @@ }, "devDependencies": { "@types/sinon": "^17.0.3", - "fast-json-stringify": "^7.0.1", + "fast-json-stringify": "^7.1.0", "node-fetch": "^3.3.2", "sinon": "^19.0.2" }, From bd6ae41807c7d7215168f12465d75be3024a763c Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 00:24:50 +0300 Subject: [PATCH 044/675] devDependencies: bump sinon from 19.0.2 to 22.1.0 @types/sinon goes from 17.0.3 to 22.0.0 with it. The tests only use `sinon.mock()` expectations, which none of the breaking changes of sinon 20 to 22 affect (the removed `usingPromise`, `fakeServer` and `assert.failException`, the move to ES modules). Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 83 +++++++++--------------------------- packages/binden/package.json | 4 +- 2 files changed, 22 insertions(+), 65 deletions(-) diff --git a/package-lock.json b/package-lock.json index 11ff8335..3da6d5b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1950,9 +1950,9 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -1960,9 +1960,9 @@ } }, "node_modules/@sinonjs/samsam": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", - "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-10.0.2.tgz", + "integrity": "sha512-8lVwD1Df1BmzoaOLhMcGGcz/Jyr5QY2KSB75/YK1QgKzoabTeLdIVyhXNZK9ojfSKSdirbXqdbsXXqP9/Ve8+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2045,9 +2045,9 @@ "license": "MIT" }, "node_modules/@types/sinon": { - "version": "17.0.4", - "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.4.tgz", - "integrity": "sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-22.0.0.tgz", + "integrity": "sha512-TDbVpbccc2HfiqHR09Argj3mHV1KMW7sCCKj52fsl8lbRLkEn7fB1966EWhOKWUBcqfBueZuPoA7/OK1CKiy3g==", "dev": true, "license": "MIT", "dependencies": { @@ -3163,9 +3163,9 @@ } }, "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -4656,13 +4656,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/just-extend": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", - "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", - "dev": true, - "license": "MIT" - }, "node_modules/keyv": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", @@ -5099,29 +5092,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nise": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.5.tgz", - "integrity": "sha512-SnRDPDBjxZZoU2n0+gzzLtSvo1OZo7j6jnbXsoh3AFxEGhaFU7ZF0TmefuKERq79wxR2U+MPn7ArW+Tl+clC3A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^15.1.1", - "just-extend": "^6.2.0", - "path-to-regexp": "^8.3.0" - } - }, - "node_modules/nise/node_modules/@sinonjs/fake-timers": { - "version": "15.4.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", - "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } - }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -7427,17 +7397,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -8156,18 +8115,16 @@ } }, "node_modules/sinon": { - "version": "19.0.5", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-19.0.5.tgz", - "integrity": "sha512-r15s9/s+ub/d4bxNXqIUmwp6imVSdTorIRaxoecYjqTVLZ8RuoXr/4EDGwIBo6Waxn7f2gnURX9zuhAfCwaF6Q==", + "version": "22.1.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-22.1.0.tgz", + "integrity": "sha512-n1ajF2rBWMTtEwbKcw4UdFg4nCnDdq/U6RDoxtOd7oapOlRoJ5ynwFx60owROyhDpA9QhMZi0pCO/xtmwFjG7w==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^13.0.5", - "@sinonjs/samsam": "^8.0.1", - "diff": "^7.0.0", - "nise": "^6.1.1", - "supports-color": "^7.2.0" + "@sinonjs/fake-timers": "^15.4.0", + "@sinonjs/samsam": "^10.0.2", + "diff": "^9.0.0" }, "funding": { "type": "opencollective", @@ -9163,10 +9120,10 @@ "@binden/logger": "^0.0.0-development" }, "devDependencies": { - "@types/sinon": "^17.0.3", + "@types/sinon": "^22.0.0", "fast-json-stringify": "^7.1.0", "node-fetch": "^3.3.2", - "sinon": "^19.0.2" + "sinon": "^22.1.0" }, "engines": { "node": ">=24.14.1", diff --git a/packages/binden/package.json b/packages/binden/package.json index 8e421cd7..893dade9 100644 --- a/packages/binden/package.json +++ b/packages/binden/package.json @@ -40,10 +40,10 @@ "@binden/logger": "^0.0.0-development" }, "devDependencies": { - "@types/sinon": "^17.0.3", + "@types/sinon": "^22.0.0", "fast-json-stringify": "^7.1.0", "node-fetch": "^3.3.2", - "sinon": "^19.0.2" + "sinon": "^22.1.0" }, "publishConfig": { "access": "public" From e4634a610d66bb0199a940b8b6bb1e865217a2cb Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 00:25:31 +0300 Subject: [PATCH 045/675] devDependencies: bump undici from 7.11.0 to 8.11.2 in @binden/body The tests of `@binden/compression` and `@binden/multipart` already use undici 8. Its isolated global dispatcher does not change the tests of `@binden/body`: `test/setup.ts` and `request()` come from the same undici. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 12 +++++++++++- packages/body/package.json | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3da6d5b1..b7b50303 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9141,7 +9141,7 @@ "devDependencies": { "@binden/logger": "^0.0.0-development", "ajv": "^8.20.0", - "undici": "^7.11.0" + "undici": "^8.11.2" }, "engines": { "node": ">=24.14.1", @@ -9155,6 +9155,16 @@ "binden": "^0.0.0-development" } }, + "packages/body/node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "packages/compression": { "name": "@binden/compression", "version": "0.0.0-development", diff --git a/packages/body/package.json b/packages/body/package.json index 944cc77e..f7d36388 100644 --- a/packages/body/package.json +++ b/packages/body/package.json @@ -41,7 +41,7 @@ "devDependencies": { "@binden/logger": "^0.0.0-development", "ajv": "^8.20.0", - "undici": "^7.11.0" + "undici": "^8.11.2" }, "publishConfig": { "access": "public" From f175aadda60be9199033eed79d6f0b6e8542d080 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 00:29:30 +0300 Subject: [PATCH 046/675] devDependencies: refresh the lockfile within the declared ranges Only `url-join` (a dependency of `@semantic-release/github`) had a newer version within its range: 5.0.0 to 5.1.0. The other dependencies are already at the latest versions their ranges allow. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b7b50303..bde2b681 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8816,9 +8816,9 @@ } }, "node_modules/url-join": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", - "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.1.0.tgz", + "integrity": "sha512-eoBT8wyeuK0x1fQLmdUY3wbBZ3NtSbt98aT7dMoMORbBtZ8tKnunHO/4bZiuOhzR7tjme9JJaCP+yQkqmUzEBA==", "dev": true, "license": "MIT", "engines": { From 756ff841394dc1669434b7f9d3f3ce6d6663eb4b Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 00:30:34 +0300 Subject: [PATCH 047/675] fix: declare the pino dependency of binden The serializers of binden import `pino` directly, which only `@binden/logger` declared: the import worked only when npm hoisted `pino` next to binden. With `--install-strategy=nested`, importing binden failed with `ERR_MODULE_NOT_FOUND`. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 3 ++- packages/binden/package.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index bde2b681..cd1ef134 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9117,7 +9117,8 @@ "version": "0.0.0-development", "license": "MIT", "dependencies": { - "@binden/logger": "^0.0.0-development" + "@binden/logger": "^0.0.0-development", + "pino": "^10.4.0" }, "devDependencies": { "@types/sinon": "^22.0.0", diff --git a/packages/binden/package.json b/packages/binden/package.json index 893dade9..123c610e 100644 --- a/packages/binden/package.json +++ b/packages/binden/package.json @@ -37,7 +37,8 @@ }, "homepage": "https://github.com/binden-js/binden/tree/main/packages/binden#readme", "dependencies": { - "@binden/logger": "^0.0.0-development" + "@binden/logger": "^0.0.0-development", + "pino": "^10.4.0" }, "devDependencies": { "@types/sinon": "^22.0.0", From a12d0117e02a1d85189e78ee74ad4b2384422fe9 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 00:30:36 +0300 Subject: [PATCH 048/675] ci: install the consumer with nested node_modules The consumer check installed the tarballs with the default hoisting, under which an undeclared dependency (such as `pino` of binden) still resolves. With `--install-strategy=nested`, every package only sees its own dependencies. Co-Authored-By: Claude Opus 5.5 --- scripts/consumer-check.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/consumer-check.mjs b/scripts/consumer-check.mjs index 7ca396a4..9b274db1 100644 --- a/scripts/consumer-check.mjs +++ b/scripts/consumer-check.mjs @@ -172,6 +172,9 @@ try { ]) { run("npm", [ "install", + // Every package gets its own node_modules: an import of a dependency that + // is not declared (but hoisted in this repository) fails + "--install-strategy=nested", "--no-audit", "--no-fund", "--loglevel=warn", From 11577d2d991c1e6a923c404f64eceafefbd36d81 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 00:46:28 +0300 Subject: [PATCH 049/675] fix: settle Binden responses left without a reply A middleware that set `context.done` without sending anything left the request pending until the client gave up, so `context.status(204).done = true` never replied. When the stack ran out without `done` although a middleware had ended (or started) the response, the 404 error that followed destroyed the connection and cut a response that was still being sent. `BindenResponse.started` tells whether a response has been started: by `send()` or `sendFile()` (also when not awaited), by a stream piped into it, or by its headers being sent; `@binden/compression` reports a response started once data went into its compressor. A stack that stops on `done` sends an empty response (with the status and headers set so far) unless it has been started, and a stack that runs out leaves a started response to finish instead of replying 404. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 4 +- packages/binden/src/binden.ts | 17 +++- packages/binden/src/response.ts | 38 ++++++++- packages/binden/test/binden.test.ts | 78 ++++++++++++++++++- packages/binden/test/response.test.ts | 22 ++++++ packages/compression/index.ts | 13 ++++ packages/compression/test/compression.test.ts | 16 ++++ 7 files changed, 184 insertions(+), 4 deletions(-) diff --git a/packages/binden/README.md b/packages/binden/README.md index 50dd18d6..0a84955a 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -123,7 +123,7 @@ const { response } = context; const { id } = context; ``` -- `.done` +- `.done` - stop passing the `context` to the following middlewares. The middleware should have started the response by then (e.g. `.send()` or a stream piped into it, even if not awaited); otherwise an empty response is sent, with the status and headers set so far. A middleware that starts the response without setting `.done` must finish it: the request is not passed on as `404 Not Found`. ```typescript import { type Context, Middleware } from "binden"; @@ -426,6 +426,8 @@ import { BindenResponse } from "binden"; const server = createServer({ ServerResponse: BindenResponse }); ``` +- `.started` - whether the response has been started: by `.send()` or `.sendFile()`, by a stream piped into it, or by its headers being sent + - `.cookies` - The `.send()` method will add cookies to the response ```typescript diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index e140df1e..43708676 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -227,6 +227,7 @@ export class Binden { next = await Binden.#runMiddleware(mw, next); if (next.done) { + await Binden.#complete(next); return; } } @@ -234,6 +235,7 @@ export class Binden { next = await Binden.#runMiddleware(item, next); if (next.done) { + await Binden.#complete(next); return; } } @@ -241,7 +243,20 @@ export class Binden { } } - throw new BindenError(404); + // Nothing has handled the request, unless a middleware has started (or ended) + // the response without setting `done`: it is left to finish + if (!next.response.started) { + throw new BindenError(404); + } + } + + /** A middleware has set `done`: a response that nothing has started is sent empty */ + static async #complete(context: Context): Promise { + if (!context.response.started) { + const extra = { context }; + Binden.#log(context, "warn", "`done` without a response", extra); + await context.response.send(); + } } /** Default error hanlder (uses the provided `error_handler`, if any) */ diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index 8fc5268b..1813b345 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -23,6 +23,16 @@ export class BindenResponse< > extends ServerResponse { #cookies?: Set; #ended = false; + #started = false; + + public constructor(...args: [request: Request, options?: unknown]) { + // node:http passes its options as well + super(...(args as [Request])); + // `Readable.pipe()` emits `pipe` on its destination synchronously + this.once("pipe", () => { + this.#started = true; + }); + } public get cookies(): Set { if (!this.#cookies) { @@ -49,6 +59,14 @@ export class BindenResponse< return super.end(...(args as [unknown, BufferEncoding])); } + /** + * Whether the response has been started: by `.send()` or `.sendFile()`, by a + * stream piped into it, or by its headers being sent + */ + public get started(): boolean { + return this.#started || this.headersSent || this.writableEnded; + } + /** Set the status code */ public status(code: number): this { if (typeof STATUS_CODES[code] === "undefined") { @@ -77,7 +95,11 @@ export class BindenResponse< ): Promise { if (this.writableEnded) { return Promise.resolve(); - } else if (!this.headersSent && this.cookies.size) { + } + + this.#started = true; + + if (!this.headersSent && this.cookies.size) { const setCookie = this.getHeader("Set-Cookie"); const values = [...this.cookies].map((c) => c.toString()); @@ -235,6 +257,20 @@ export class BindenResponse< public async sendFile( path: URL | string, options?: Pick, + ): Promise { + // Started at once (see `.started`), unless it fails before sending anything + this.#started = true; + try { + await this.#sendFile(path, options); + } catch (error: unknown) { + this.#started = false; + throw error; + } + } + + async #sendFile( + path: URL | string, + options?: Pick, ): Promise { const url = path instanceof URL ? new URL(path.href) : pathToFileURL(path); diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index 8a8c5933..de13584a 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import { Server } from "node:http"; import { Agent } from "node:https"; +import { Readable } from "node:stream"; import { afterEach, beforeEach, suite, test } from "node:test"; import { Logger, type LoggerOptions } from "@binden/logger"; import fetch from "node-fetch"; @@ -465,6 +466,77 @@ suite("Binden", () => { deepStrictEqual(response.status, 404); }); + test("`done` without a response sends an empty one", async () => { + app.use("/empty", (context: Context) => { + context.done = true; + }); + app.use("/no-content", (context: Context) => { + context.status(204).setHeader("X-Header", "Value").done = true; + }); + const signal = AbortSignal.timeout(5000); + + const empty = await fetch(`${url}/empty`, { signal }); + deepStrictEqual(empty.status, 200); + deepStrictEqual(await empty.text(), ""); + + const noContent = await fetch(`${url}/no-content`, { signal }); + deepStrictEqual(noContent.status, 204); + deepStrictEqual(noContent.headers.get("X-Header"), "Value"); + }); + + test("`done` with a response that is still starting", async () => { + // Sends "a" and "b" a little later (as a file is read after the stack) + const slow = (): Readable => { + let reading = false; + return new Readable({ + read(): void { + if (!reading) { + reading = true; + setTimeout(() => { + this.push("a"); + setTimeout(() => { + this.push("b"); + this.push(null); + }, 20); + }, 20); + } + }, + }); + }; + app.use("/send", (context: Context) => { + context.response.send(slow()).catch(() => { + // The body then fails the test + }); + context.done = true; + }); + app.use("/pipe", (context: Context) => { + slow().pipe(context.response); + context.done = true; + }); + const signal = AbortSignal.timeout(5000); + + deepStrictEqual( + await (await fetch(`${url}/send`, { signal })).text(), + "ab", + ); + deepStrictEqual( + await (await fetch(`${url}/pipe`, { signal })).text(), + "ab", + ); + }); + + test("a response ended without `done` is not cut by a 404", async () => { + const size = 32 * 1024 * 1024; + app.use((context: Context) => { + context.response.end(Buffer.alloc(size)); + }); + + const response = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + + deepStrictEqual(response.status, 200); + deepStrictEqual((await response.arrayBuffer()).byteLength, size); + }); + test("404 error (Empty `guarded` router)", async () => { app.use(new Router({ guarded: true })); const response = await fetch(url, { method: "POST" }); @@ -752,5 +824,9 @@ suite("Binden", () => { }); }); - afterEach((_t, done) => server.close(done)); + afterEach((_t, done) => { + // Also the connections of a failed test, which would keep the process alive + server.closeAllConnections(); + server.close(done); + }); }); diff --git a/packages/binden/test/response.test.ts b/packages/binden/test/response.test.ts index 4e8e73ff..fd290283 100644 --- a/packages/binden/test/response.test.ts +++ b/packages/binden/test/response.test.ts @@ -261,6 +261,28 @@ suite("BindenResponse", () => { }, ); + test(".started", async () => { + // Without a connection: nothing is actually sent + const create = (): BindenResponse => + new BindenResponse(new IncomingMessage(new Socket())); + + const sent = create(); + ok(!sent.started); + sent.send("data").catch(() => { + // Never settles without a connection + }); + ok(sent.started); + + const piped = create(); + Readable.from(["data"]).pipe(piped); + ok(piped.started); + + // A `sendFile()` that fails before sending anything does not start it + const failed = create(); + await rejects(() => failed.sendFile(dirPath, dir_stats)); + ok(!failed.started); + }); + test(".send() (settles with the events of the response)", async () => { // Without a connection, `finish` never comes: the events are emitted here const create = (): BindenResponse => diff --git a/packages/compression/index.ts b/packages/compression/index.ts index 96cc5955..4d2f4641 100644 --- a/packages/compression/index.ts +++ b/packages/compression/index.ts @@ -133,6 +133,19 @@ export class Compression extends Middleware { get: () => ended, }); + // Written data goes into the compressor before the headers are sent: the + // response is started (see `BindenResponse.started`) once compression is + Object.defineProperty(response, "started", { + configurable: true, + get: (): boolean => + active === true || + (Reflect.get( + Object.getPrototypeOf(response) as object, + "started", + response, + ) as boolean), + }); + response.end = (...args: unknown[]): typeof response => { const callback = args.find((arg) => typeof arg === "function"); if (typeof callback === "function") { diff --git a/packages/compression/test/compression.test.ts b/packages/compression/test/compression.test.ts index 4d927bc2..39503377 100644 --- a/packages/compression/test/compression.test.ts +++ b/packages/compression/test/compression.test.ts @@ -731,6 +731,22 @@ describe("Compression", () => { }, ); + it("keeps a response started by compressed data (`done`)", async () => { + app.use(new Compression({ format: "gzip" }), (ct: Context) => { + // Waits in the compressor: the headers are not sent yet + ct.response.write("x"); + ct.done = true; + setTimeout(() => { + ct.response.end("y"); + }, 50); + }); + + const response = await request(url); + const body = await response.body.arrayBuffer(); + + deepStrictEqual((await gunzip(Buffer.from(body))).toString(), "xy"); + }); + it("does not compress when the headers have already been sent", async () => { const expected = "Hello World"; app.use(new Compression({ format: "gzip" }), (ct: Context) => { From 5e5e3515ab348bca84bde6bec7a52933b678517b Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 00:48:24 +0300 Subject: [PATCH 050/675] fix: await error handlers in binden The result of a custom `error_handler` was neither awaited nor handled: an async handler that rejected crashed the process with an unhandled rejection, and a handler that did not reply left the request pending. `error_handler` may now return a promise, which is awaited. When it has not started a response, the default reply is sent, and when it fails, the connection is closed (and the failure logged), as for a synchronous handler. The README lists the options of `Binden`. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 6 +++ packages/binden/src/binden.ts | 38 ++++++++++----- packages/binden/test/binden.test.ts | 73 +++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 12 deletions(-) diff --git a/packages/binden/README.md b/packages/binden/README.md index 0a84955a..547fdcf9 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -12,6 +12,12 @@ npm install binden ### Binden +`new Binden(options)` takes the following options: + +- `auto_head` (default `true`) - a `guarded` router that handles `GET` handles `HEAD` as well +- `error_handler` - replaces the default reply to an error thrown by a middleware. It may be `async`: it is awaited, and the default reply is sent when it has not started a response. When it fails, the connection is closed. +- `logger` - see [Logging](#logging) + - `.use()` - Add a `Middleware/Router` to the stack ```typescript diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index 43708676..409feba5 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -21,7 +21,8 @@ type ILogLevel = "debug" | "error" | "trace" | "warn"; export interface IBindenOptions { auto_head?: boolean; - error_handler?: (context: Context, error: unknown) => void; + /** Replaces the default reply to an error, unless it does not reply itself */ + error_handler?: (context: Context, error: unknown) => Promise | void; /** The logger of the contexts (the default one is set up by `BINDEN_LOG_LEVEL`) */ logger?: Logger; } @@ -29,7 +30,8 @@ export interface IBindenOptions { export class Binden { readonly #stack: IStack[]; readonly #auto_head: boolean; - readonly #error_handler: ((context: Context, error: unknown) => void) | null; + readonly #error_handler: + ((context: Context, error: unknown) => Promise | void) | null; readonly #logger: Logger; public constructor({ @@ -183,9 +185,7 @@ export class Binden { ): void { const context = new Context({ request, response, logger: this.#logger }); this.#handle(context) - .catch((error: unknown) => { - this.#errorHandler(context, error); - }) + .catch(async (error: unknown) => this.#errorHandler(context, error)) .catch((error: unknown) => { // The error handler failed (e.g. a custom `error_handler` threw): the // response cannot be completed, but the process must not crash @@ -259,19 +259,33 @@ export class Binden { } } - /** Default error hanlder (uses the provided `error_handler`, if any) */ - #errorHandler(context: Context, error: unknown): void { + /** Error handler: the provided `error_handler` (awaited), then the default reply if needed */ + async #errorHandler(context: Context, error: unknown): Promise { + if (this.#error_handler) { + const base = { context, error }; + const message = "Passing error to the provided `error_handler`"; + Binden.#log(context, "trace", message, base); + + await this.#error_handler(context, error); + + if (context.response.started) { + return; + } + const ignored = "The provided `error_handler` has not replied"; + Binden.#log(context, "debug", ignored, base); + } + Binden.#defaultErrorHandler(context, error); + } + + /** Default reply to an error */ + static #defaultErrorHandler(context: Context, error: unknown): void { const { response, request } = context; // Only the context and the error, whose serializers keep the input out of the logs const base = { context, error }; const log = (level: ILogLevel, message: string): void => { Binden.#log(context, level, message, base); }; - if (this.#error_handler) { - log("trace", "Passing error to the provided `error_handler`"); - - this.#error_handler(context, error); - } else if (response.writableEnded) { + if (response.writableEnded) { log("debug", "`.end()` has been called. Destroying the socket"); request.destroy(); diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index de13584a..714b5099 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -401,6 +401,79 @@ suite("Binden", () => { } }); + test("awaits an async `error_handler`", async () => { + const logged = await loggedApp({ + error_handler: async (context: Context): Promise => { + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + context.response.status(503).end("later"); + }, + }); + try { + logged.app.use(() => { + throw new BindenError(400); + }); + + const response = await fetch(logged.url, { + signal: AbortSignal.timeout(5000), + }); + + deepStrictEqual(response.status, 503); + deepStrictEqual(await response.text(), "later"); + } finally { + await logged.close(); + } + }); + + test("replies by default when the `error_handler` does not", async () => { + const logged = await loggedApp({ + error_handler: (): void => { + // Only observes the error + }, + }); + try { + logged.app.use(() => { + throw new BindenError(418, { expose: true, message: "teapot" }); + }); + + const response = await fetch(logged.url, { + signal: AbortSignal.timeout(5000), + }); + + deepStrictEqual(response.status, 418); + deepStrictEqual(await response.text(), "teapot"); + ok(logged.output().includes("has not replied"), logged.output()); + } finally { + await logged.close(); + } + }); + + test("closes the connection when an async error handler fails", async () => { + const logged = await loggedApp({ + error_handler: async (): Promise => { + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + throw new Error("A failing error handler"); + }, + }); + try { + logged.app.use(() => { + throw new BindenError(400); + }); + + // The connection is closed (not left open until the deadline) + await rejects( + fetch(logged.url, { signal: AbortSignal.timeout(5000) }), + (error: Error) => error.name !== "AbortError", + ); + ok(logged.output().includes("The error handler failed"), logged.output()); + } finally { + await logged.close(); + } + }); + test("closes the connection when the error handler fails", async () => { const logged = await loggedApp({ error_handler: (): never => { From fdb41a23df6104fcfeab43fd35e2e6fb0fe8cc64 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 00:55:34 +0300 Subject: [PATCH 051/675] fix: settle send(Readable) on the bytes flushed to the connection in binden `send(Readable)` counted the bytes read from the source, so a response closed after a large chunk had been read (but not sent) was taken as complete: a client that went away after 64 KiB of a 64 MiB response left `send()` resolved. Ending the response (`end()`) did not mean that the client had it either, and Node.js 24 emits `finish` after a failed write once the response is ended. The source is now written chunk by chunk (with backpressure). A chunk counts once its write has flushed it to the connection, and a failed write rejects `send()` with its error. A response closed before `finish` is complete only when the client already has its body: the flushed bytes reach its `Content-Length`, or it is a `HEAD` response. A source that does not produce bytes (e.g. objects) is destroyed and rejects `send()`, instead of throwing from its `data` event. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/response.ts | 74 +++++++++++---------- packages/binden/test/response.test.ts | 94 ++++++++++++++++++++++++++- 2 files changed, 131 insertions(+), 37 deletions(-) diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index 1813b345..1a7a29b5 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -22,7 +22,6 @@ export class BindenResponse< Request extends IncomingMessage = IncomingMessage, > extends ServerResponse { #cookies?: Set; - #ended = false; #started = false; public constructor(...args: [request: Request, options?: unknown]) { @@ -41,24 +40,6 @@ export class BindenResponse< return this.#cookies; } - /** - * End the response. Once called, the response is handed over to the connection: - * a close that comes before `finish` is no longer premature (see `.send()`). - */ - public override end(cb?: () => void): this; - /** End the response with a last `chunk` (see the first signature) */ - public override end(chunk: unknown, cb?: () => void): this; - /** End the response with a last `chunk` in an `encoding` (see the first signature) */ - public override end( - chunk: unknown, - encoding: BufferEncoding, - cb?: () => void, - ): this; - public override end(...args: unknown[]): this { - this.#ended = true; - return super.end(...(args as [unknown, BufferEncoding])); - } - /** * Whether the response has been started: by `.send()` or `.sendFile()`, by a * stream piped into it, or by its headers being sent @@ -118,8 +99,8 @@ export class BindenResponse< } // Settles once the response has finished, or rejects when it is closed - // before `end()` (e.g. the client went away) - const done = this.#finished(() => this.#ended); + // before (e.g. the client went away) + const done = this.#finished(() => false); if (typeof data === "undefined" || data === "") { this.end(); @@ -170,17 +151,19 @@ export class BindenResponse< } /** Send a file */ - /** Pipes a `Readable` into the response and settles like `#finished()` */ + /** + * Writes a `Readable` into the response (with backpressure) and settles like + * `#finished()`. The client has the whole response (and may close the + * connection) once the `Content-Length` bytes (none for `HEAD`) are flushed to + * the connection, maybe before the source ends. The header is read at the + * close: compression removes it. + */ #pipe(data: Readable): Promise { - // The client has the whole response (and may close the connection) once it - // has the `Content-Length` bytes (none for `HEAD`), maybe before the source - // ends. The header is read at the close: compression removes it. - let received = 0; + let flushed = 0; const done = this.#finished( () => - this.#ended || this.req.method === "HEAD" || - received >= Number(this.getHeader("Content-Length")), + flushed >= Number(this.getHeader("Content-Length")), ); return new Promise((resolve, reject) => { this.once("close", () => { @@ -191,10 +174,33 @@ export class BindenResponse< }); data .on("data", (chunk: Uint8Array | string) => { - received += Buffer.byteLength(chunk); + try { + const size = Buffer.byteLength(chunk); + const written = this.write(chunk, (error?: Error | null) => { + if (error) { + // Not delivered: the client went away (Node.js 24 may still + // emit `finish` once the response is ended) + data.destroy(); + reject(error); + } else { + flushed += size; + } + }); + if (!written) { + data.pause(); + this.once("drain", () => { + data.resume(); + }); + } + } catch (error: unknown) { + // Not a chunk of bytes (e.g. an object) + data.destroy(error as Error); + } + }) + .once("end", () => { + this.end(); }) - .once("error", reject) - .pipe(this); + .once("error", reject); done.then(resolve, (error: Error) => { data.destroy(); reject(error); @@ -203,9 +209,9 @@ export class BindenResponse< } /** - * Resolves when the response has finished, rejects when it is closed (e.g. the - * client went away) before it is `complete()` for the client: `end()` has - * handed it over to the connection (`#ended`), or the client has its body + * Resolves when the response has finished (flushed to the connection), rejects + * when it is closed before (e.g. the client went away), unless it is + * `complete()` for the client already */ #finished(complete: () => boolean): Promise { return new Promise((resolve, reject) => { diff --git a/packages/binden/test/response.test.ts b/packages/binden/test/response.test.ts index fd290283..5292a192 100644 --- a/packages/binden/test/response.test.ts +++ b/packages/binden/test/response.test.ts @@ -38,6 +38,9 @@ function abortAfterFirstChunk(): Promise { }); } +/** The error codes of a `send()` to a client that went away */ +const gone = new Set(["EPIPE", "ECONNRESET", "ERR_STREAM_PREMATURE_CLOSE"]); + /** The error code of a settled `send()` (`null` when it has resolved) */ function settle(promise: Promise): Promise { return promise.then( @@ -288,11 +291,16 @@ suite("BindenResponse", () => { const create = (): BindenResponse => new BindenResponse(new IncomingMessage(new Socket())); - // The connection may close before `finish` once `end()` has been called + // Ended, but not flushed to the connection: the close is premature const ended = create(); const sent = ended.send("data"); ended.emit("close"); - deepStrictEqual(await settle(sent), null); + deepStrictEqual(await settle(sent), "ERR_STREAM_PREMATURE_CLOSE"); + + const finished = create(); + const finishing = finished.send("data"); + finished.emit("finish"); + deepStrictEqual(await settle(finishing), null); const failed = create(); const failing = failed.send("data"); @@ -351,6 +359,85 @@ suite("BindenResponse", () => { }, ); + test( + ".send() (rejects when the client goes away before the flush)", + { timeout: 10_000 }, + async () => { + // One chunk with the whole `Content-Length`: read at once, but not sent + const size = 64 * 1024 * 1024; + const stream = Readable.from([Buffer.alloc(size)]); + const settled = new Promise((resolve) => { + server.once("request", (_request, response) => { + response.setHeader("Content-Length", size); + resolve(settle(response.send(stream))); + }); + }); + await abortAfterFirstChunk(); + + // The write fails (or the response closes) before the chunk is flushed + const code = await settled; + ok(gone.has(code as string), String(code)); + ok(stream.destroyed); + }, + ); + + test( + ".send() (pauses a `Readable` while the client does not read)", + { timeout: 10_000 }, + async () => { + const chunk = Buffer.alloc(1024 * 1024); + let left = 64; + const stream = new Readable({ + read(): void { + left -= 1; + this.push(left >= 0 ? chunk : null); + }, + }); + const settled = new Promise((resolve) => { + server.once("request", (_request, response) => { + resolve(settle(response.send(stream))); + }); + }); + const response = await new Promise((resolve) => { + get(url, resolve); + }); + response.pause(); + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + + // The connection is full: the source waits for `drain` + deepStrictEqual(stream.readableFlowing, false); + ok(left > 0); + + let received = 0; + for await (const data of response) { + received += (data as Buffer).byteLength; + } + deepStrictEqual(received, 64 * chunk.byteLength); + deepStrictEqual(await settled, null); + }, + ); + + test(".send() (rejects a `Readable` of objects)", async () => { + const stream = Readable.from([{ not: "a chunk" }]); + const settled = new Promise((resolve) => { + server.once("request", (_request, response) => { + resolve(settle(response.send(stream))); + response.once("close", () => { + // The test ends the connection below + }); + }); + }); + const request = get(url).on("error", () => { + // The connection is closed below + }); + + deepStrictEqual(await settled, "ERR_INVALID_ARG_TYPE"); + ok(stream.destroyed); + request.destroy(); + }); + test(".send() (a `Readable` without its whole `Content-Length`)", async () => { // Without a connection: the events are emitted here const create = (method: string): BindenResponse => @@ -406,7 +493,8 @@ suite("BindenResponse", () => { }); await abortAfterFirstChunk(); - deepStrictEqual(await settled, "ERR_STREAM_PREMATURE_CLOSE"); + const code = await settled; + ok(gone.has(code as string), String(code)); ok(stream.destroyed && !stream.readableEnded); }, ); From 8b0c1cc77d0504ae8e45b5cd21acd3df6c44a46c Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 01:01:00 +0300 Subject: [PATCH 052/675] fix!: log an allowlist of request details by default in binden The default serializers only redacted a few headers: other credentials (e.g. `X-Api-Key`), the fields an application sets on a context, and error codes or class names that come from the input (e.g. `ERR_SECRET_ABC123`, or a secret in `constructor.name` or `Symbol.toStringTag`) still reached the logs. The serializers now build their output from an allowlist: - a request is its method, its path and its `Host`, `User-Agent`, `Content-Type` and `Content-Length` headers; a response is its status code and its `Content-Type` and `Content-Length` headers; - a context is its `id`, request and response; - the type of an error is the nearest of `BindenError` and the built-in error classes in its prototype chain, and its `code` is kept only when it is a system error code or one of a few stream and HTTP codes. binden no longer needs `pino` itself. BREAKING CHANGE: the default logs of binden contain fewer details: no other request or response headers, no own fields of a context, no names of custom error classes and no other error codes. A custom logger can log them (see the README). Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 3 +- packages/binden/README.md | 8 +- packages/binden/package.json | 3 +- packages/binden/src/logger.ts | 148 +++++++++++++++++----------- packages/binden/test/binden.test.ts | 28 ++++++ packages/binden/test/logger.test.ts | 121 ++++++++++++++++------- 6 files changed, 211 insertions(+), 100 deletions(-) diff --git a/package-lock.json b/package-lock.json index cd1ef134..bde2b681 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9117,8 +9117,7 @@ "version": "0.0.0-development", "license": "MIT", "dependencies": { - "@binden/logger": "^0.0.0-development", - "pino": "^10.4.0" + "@binden/logger": "^0.0.0-development" }, "devDependencies": { "@types/sinon": "^22.0.0", diff --git a/packages/binden/README.md b/packages/binden/README.md index 547fdcf9..0e693b78 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -63,11 +63,13 @@ const secureServer = app.createSecureServer({ key, cert }); ### Logging -Every context logs through `context.log`: a child (with the `trace_id` of the context) of the logger of the application. The default logger (see [`@binden/logger`](https://github.com/binden-js/binden/tree/main/packages/logger)) is silent unless the `BINDEN_LOG_LEVEL` environment variable sets a level, and its `serializers` keep credentials and the input of the requests out of the logs: +Every context logs through `context.log`: a child (with the `trace_id` of the context) of the logger of the application. The default logger (see [`@binden/logger`](https://github.com/binden-js/binden/tree/main/packages/logger)) is silent unless the `BINDEN_LOG_LEVEL` environment variable sets a level, and its `serializers` log an allowlist of details only, which keeps credentials and the input of the requests out of the logs: -- the `Authorization`, `Cookie`, `Proxy-Authorization` and `Set-Cookie` headers are redacted, and the query string is dropped from the request URL; +- a request is its method, its path (without the query string) and its `Host`, `User-Agent`, `Content-Type` and `Content-Length` headers; +- a response is its status code and its `Content-Type` and `Content-Length` headers; +- a context is its `id`, request and response (not the fields the application sets on it); - a logged `body` (e.g. the one that [`@binden/body`](https://github.com/binden-js/binden/tree/main/packages/body) failed to parse) is redacted; -- an `error` is reduced to its `type`, `status` and `expose`, and to its `code` when it is a Node.js error code (such as `ECONNRESET` or `ERR_STREAM_PREMATURE_CLOSE`): its message, stack, cause and `json` may quote the input (e.g. an error of `JSON.parse()`). The serializer never throws, whatever is thrown. +- an `error` is reduced to its type (the nearest of `BindenError` and the built-in error classes, never a name the error defines), its `status` and `expose`, and its `code` when it is a system error code (such as `ECONNRESET`) or one of a few stream and HTTP codes (such as `ERR_STREAM_PREMATURE_CLOSE`). Its message, stack, cause and `json` may quote the input (e.g. an error of `JSON.parse()`). The serializer never throws, whatever is thrown. A custom logger (for example with another level or destination, or to log more details) is passed with the `logger` option. The default `serializers` are exported to build on: diff --git a/packages/binden/package.json b/packages/binden/package.json index 123c610e..893dade9 100644 --- a/packages/binden/package.json +++ b/packages/binden/package.json @@ -37,8 +37,7 @@ }, "homepage": "https://github.com/binden-js/binden/tree/main/packages/binden#readme", "dependencies": { - "@binden/logger": "^0.0.0-development", - "pino": "^10.4.0" + "@binden/logger": "^0.0.0-development" }, "devDependencies": { "@types/sinon": "^22.0.0", diff --git a/packages/binden/src/logger.ts b/packages/binden/src/logger.ts index 6fea1102..dee89e78 100644 --- a/packages/binden/src/logger.ts +++ b/packages/binden/src/logger.ts @@ -1,29 +1,40 @@ -import { STATUS_CODES } from "node:http"; -import Pino from "pino"; +import { IncomingMessage, ServerResponse, STATUS_CODES } from "node:http"; +import { constants } from "node:os"; import { Logger } from "@binden/logger"; import { Context } from "./context.js"; +import { BindenError } from "./error.js"; -const redacted = "[Redacted]"; +/** The request headers that the default logger keeps (others may carry credentials) */ +const requestHeaders = ["host", "user-agent", "content-type", "content-length"]; -/** Headers that carry credentials and must never be logged */ -const sensitive: ReadonlySet = new Set([ - "authorization", - "cookie", - "proxy-authorization", - "set-cookie", -]); +/** The response headers that the default logger keeps */ +const responseHeaders = ["content-type", "content-length"]; -function redact(headers: Record): Record { - return Object.fromEntries( - Object.entries(headers).map(([name, value]) => [ - name, - sensitive.has(name.toLowerCase()) ? redacted : value, - ]), - ); -} +/** The error classes the default logger names (a subclass gets the name of its parent) */ +const errorTypes: ReadonlyMap = new Map([ + [BindenError.prototype, "BindenError"], + [AggregateError.prototype, "AggregateError"], + [EvalError.prototype, "EvalError"], + [RangeError.prototype, "RangeError"], + [ReferenceError.prototype, "ReferenceError"], + [SyntaxError.prototype, "SyntaxError"], + [TypeError.prototype, "TypeError"], + [URIError.prototype, "URIError"], + [Error.prototype, "Error"], +]); -/** Node.js error codes: system (`ECONNRESET`) and internal (`ERR_STREAM_DESTROYED`) ones */ -const nodeErrorCode = /^(?:E[A-Z0-9]+|ERR_[A-Z0-9_]+)$/u; +/** The error codes the default logger keeps: system errors and some stream/HTTP ones */ +const errorCodes: ReadonlySet = new Set([ + ...Object.keys(constants.errno), + "ERR_HTTP_HEADERS_SENT", + "ERR_HTTP_INVALID_HEADER_VALUE", + "ERR_INVALID_ARG_TYPE", + "ERR_INVALID_CHAR", + "ERR_STREAM_ALREADY_FINISHED", + "ERR_STREAM_DESTROYED", + "ERR_STREAM_PREMATURE_CLOSE", + "ERR_STREAM_WRITE_AFTER_END", +]); /** Reads a property, so that a throwing getter (or proxy) cannot break the logs */ function get(value: object, key: string): unknown { @@ -34,29 +45,47 @@ function get(value: object, key: string): unknown { } } -/** The name of the constructor (as pino), otherwise the `Object.prototype.toString()` tag */ +/** The name of the nearest known error class of a value (never one it defines itself) */ function typeOf(value: object): string { - const constructor = get(value, "constructor"); - const name = - typeof constructor === "function" ? get(constructor, "name") : null; - if (typeof name === "string" && name !== "") { - return name; - } try { - return Object.prototype.toString.call(value).slice(8, -1); + let prototype = Object.getPrototypeOf(value) as object | null; + while (prototype !== null) { + const type = errorTypes.get(prototype); + if (typeof type === "string") { + return type; + } + prototype = Object.getPrototypeOf(prototype) as object | null; + } } catch { - return typeof value; + // A proxy that refuses to tell its prototype + } + return typeof value; +} + +/** The given headers, when they are set */ +function pick( + names: readonly string[], + header: (name: string) => unknown, +): Record { + const picked: Record = {}; + for (const name of names) { + const value = header(name); + if (typeof value !== "undefined") { + picked[name] = value; + } } + return picked; } /** - * The serializers of the default logger, which keep credentials, query strings, - * request bodies and error details (messages, stacks, causes and `json`, which - * may quote the input, e.g. the one of `JSON.parse()`) out of the logs + * The serializers of the default logger. They log an allowlist of details only, + * which keeps credentials, query strings, request bodies, error details + * (messages, stacks, causes and `json`, which may quote the input) and the own + * fields of a context out of the logs. */ export const serializers = { - // Only the kind of an error: its type, `status`, `expose` and a Node.js - // `code`. Never throws, whatever is thrown. + // The kind of an error: its type, `status`, `expose` and a known `code`. Never + // throws, whatever is thrown. error: (error: unknown): Record => { if (error === null) { return { type: "null" }; @@ -72,31 +101,40 @@ export const serializers = { ? { status } : {}), ...(typeof expose === "boolean" ? { expose } : {}), - ...(typeof code === "string" && nodeErrorCode.test(code) ? { code } : {}), + ...(typeof code === "string" && errorCodes.has(code) ? { code } : {}), }; }, - body: (): string => redacted, - // The query string (and the parsed `query`/`params`) may contain tokens - request: Pino.stdSerializers.wrapRequestSerializer( - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - ({ id: _id, query: _query, params: _params, url, headers, ...rest }) => ({ - ...rest, - url: url.split("?", 1)[0], - headers: redact(headers), - }), - ), - response: Pino.stdSerializers.wrapResponseSerializer( - ({ headers, ...rest }) => ({ ...rest, headers: redact(headers) }), - ), + body: (): string => "[Redacted]", + // The method, the path (without the query string) and some headers + request: (request: unknown): unknown => { + if (!(request instanceof IncomingMessage)) { + return request; + } + return { + method: request.method, + url: (request.url ?? "").split("?", 1)[0], + headers: pick(requestHeaders, (name) => request.headers[name]), + }; + }, + response: (response: unknown): unknown => { + if (!(response instanceof ServerResponse)) { + return response; + } + return { + statusCode: response.statusCode, + headers: pick(responseHeaders, (name) => response.getHeader(name)), + }; + }, + // Not the own fields of the context, which the application may have set context: (input: unknown): unknown => { - if (input instanceof Context) { - return { - ...input, - request: serializers.request(input.request), - response: serializers.response(input.response), - }; + if (!(input instanceof Context)) { + return input; } - return input; + return { + id: input.id, + request: serializers.request(input.request), + response: serializers.response(input.response), + }; }, }; diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index 714b5099..cdfa5a5a 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -376,6 +376,34 @@ suite("Binden", () => { } }); + test("logs an allowlist of request details", async () => { + const logged = await loggedApp(); + try { + class SECRET_CLASS extends Error {} + logged.app.use((context: Context) => { + Object.assign(context, { privateSession: "SECRET_SESSION" }); + throw Object.assign(new SECRET_CLASS("SECRET_MESSAGE"), { + code: "ERR_SECRET_ABC123", + [Symbol.toStringTag]: "SECRET_TAG", + }); + }); + + const response = await fetch(`${logged.url}/path?token=SECRET_QUERY`, { + headers: { "X-Api-Key": "SECRET_KEY", "User-Agent": "binden-test" }, + signal: AbortSignal.timeout(5000), + }); + + deepStrictEqual(response.status, DefaultErrorCode); + const output = logged.output(); + ok(!output.includes("SECRET"), output); + ok(output.includes(`"error":{"type":"Error"}`), output); + ok(output.includes(`"url":"/path"`), output); + ok(output.includes(`"user-agent":"binden-test"`), output); + } finally { + await logged.close(); + } + }); + test("replies when the logger fails", async () => { const logged = await loggedApp( {}, diff --git a/packages/binden/test/logger.test.ts b/packages/binden/test/logger.test.ts index 57a38c83..5eb6b74d 100644 --- a/packages/binden/test/logger.test.ts +++ b/packages/binden/test/logger.test.ts @@ -16,6 +16,9 @@ const secrets = [ "secret-cookie", "secret-query", "secret-set-cookie", + "secret-api-key", + "secret-header", + "secret-session", "secret-body", "secret-message", "secret-cause", @@ -28,14 +31,22 @@ function createContext(): Context { request.url = "/path?token=secret-query"; request.headers = { host: "localhost", + "user-agent": "test", + "content-type": "text/plain", + "content-length": "4", authorization: "Bearer secret-token", "proxy-authorization": "Basic secret-token", cookie: "session=secret-cookie", + "x-api-key": "secret-api-key", }; const response = new BindenResponse(request); response.setHeader("Set-Cookie", "session=secret-set-cookie"); + response.setHeader("X-Header", "secret-header"); response.setHeader("Content-Type", "text/plain"); - return new Context({ request, response }); + // A field that the application has set on the context + return Object.assign(new Context({ request, response }), { + privateSession: "secret-session", + }); } function assertNoSecrets(output: string): void { @@ -45,43 +56,50 @@ function assertNoSecrets(output: string): void { } suite("serializers", () => { - test("request: no query and redacted credentials", () => { + test("request: an allowlist of details", () => { const { request } = createContext(); - const serialized = serializers.request(request) as { - method: string; - url: string; - headers: Record; - }; - const { method, url, headers } = serialized; - deepStrictEqual({ method, url }, { method: "GET", url: "/path" }); - ok(!("query" in serialized)); - ok(!("params" in serialized)); - deepStrictEqual(headers, { - host: "localhost", - authorization: "[Redacted]", - "proxy-authorization": "[Redacted]", - cookie: "[Redacted]", + deepStrictEqual(serializers.request(request), { + method: "GET", + url: "/path", + headers: { + host: "localhost", + "user-agent": "test", + "content-type": "text/plain", + "content-length": "4", + }, + }); + + // No URL at all (`url` is optional) + const bare = new BindenRequest(new Socket()); + Reflect.deleteProperty(bare, "url"); + deepStrictEqual(serializers.request(bare), { + method: null, + url: "", + headers: {}, }); + deepStrictEqual(serializers.request("not a request"), "not a request"); }); - test("response: redacted cookies", () => { + test("response: an allowlist of details", () => { const { response } = createContext(); - const { headers } = serializers.response(response) as { - headers: Record; - }; - deepStrictEqual(headers, { - "set-cookie": "[Redacted]", - "content-type": "text/plain", + deepStrictEqual(serializers.response(response), { + statusCode: 200, + headers: { "content-type": "text/plain" }, }); + deepStrictEqual(serializers.response("not a response"), "not a response"); }); - test("context", () => { + test("context: not its own fields", () => { const context = createContext(); - const serialized = serializers.context(context); - assertNoSecrets(JSON.stringify(serialized)); + deepStrictEqual(serializers.context(context), { + id: context.id, + request: serializers.request(context.request), + response: serializers.response(context.response), + }); + assertNoSecrets(JSON.stringify(serializers.context(context))); deepStrictEqual(serializers.context("not a context"), "not a context"); }); @@ -122,12 +140,12 @@ suite("serializers", () => { }); deepStrictEqual(serializers.error(invalid), { type: "TypeError" }); - // Only Node.js error codes: any other `code` may come from the input + // Only known codes: any other `code` may come from the input for (const code of [ "secret-code", "SECRET-CODE", - "Esecret", - "ERR_secret", + "ESECRET", + "ERR_SECRET_ABC123", ]) { deepStrictEqual(serializers.error(Object.assign(new Error(), { code })), { type: "Error", @@ -136,24 +154,51 @@ suite("serializers", () => { deepStrictEqual( serializers.error({ status: 400, expose: true, message: "secret" }), - { type: "Object", status: 400, expose: true }, + { type: "object", status: 400, expose: true }, ); - deepStrictEqual(serializers.error({ status: 999 }), { type: "Object" }); + deepStrictEqual(serializers.error({ status: 999 }), { type: "object" }); deepStrictEqual(serializers.error("secret-message"), { type: "string" }); deepStrictEqual(serializers.error(null), { type: "null" }); }); + test("error: the type of a known error class only", () => { + // Names the application (or the input) chooses are not logged + class SECRET_CLASS extends RangeError {} + deepStrictEqual(serializers.error(new SECRET_CLASS()), { + type: "RangeError", + }); + + const tagged = Object.assign(new Error(), { + [Symbol.toStringTag]: "SECRET_TAG", + name: "SECRET_NAME", + }); + deepStrictEqual(serializers.error(tagged), { type: "Error" }); + + const errors = [ + new AggregateError([]), + new EvalError(), + new ReferenceError(), + new SyntaxError(), + new URIError(), + ]; + deepStrictEqual( + errors.map((error) => serializers.error(error).type), + [ + "AggregateError", + "EvalError", + "ReferenceError", + "SyntaxError", + "URIError", + ], + ); + }); + test("error: never throws", () => { const anonymous = Object.assign(new Error("secret-message"), { constructor: null, }); deepStrictEqual(serializers.error(anonymous), { type: "Error" }); - // An anonymous class - deepStrictEqual(serializers.error(new (class extends Error {})()), { - type: "Error", - }); - const getters = new Error("secret-message"); for (const key of ["constructor", "status", "expose", "code"]) { Object.defineProperty(getters, key, { @@ -168,7 +213,7 @@ suite("serializers", () => { status: 404, }); deepStrictEqual(serializers.error(prototypeless), { - type: "Object", + type: "object", status: 404, }); @@ -185,7 +230,7 @@ suite("serializers", () => { }); deepStrictEqual(serializers.error(traps), { type: "object" }); - deepStrictEqual(serializers.error(thrower), { type: "Function" }); + deepStrictEqual(serializers.error(thrower), { type: "function" }); }); test("body", () => { From 662dc7ed52e7cbdae48e520897dfb627234ba224 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 01:06:21 +0300 Subject: [PATCH 053/675] fix: reset the framing of error responses in binden The default error reply kept the headers that a middleware had set for its own body: a `Content-Length: 99` left the client waiting for 99 bytes of an empty error response, and a `Content-Encoding` or `ETag` described a body that was not sent. An error after part of the body was sent ended the response, which made the truncated body look complete, and an error after the response was ended destroyed the connection, which cut a response that was still being sent. The default reply now removes the framing and representation headers of the body (`Content-Length`, `Transfer-Encoding`, `Content-Encoding`, `Content-Range`, `Content-Disposition`, `ETag`, `Last-Modified`) and keeps `Content-Type` and `Set-Cookie`. A started response (also one whose first bytes are in the compressor of `@binden/compression`) is cut by closing the connection, and an ended response is left to finish. `send(Readable)` no longer counts as started when its source fails before sending anything, so that error still gets its reply. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/binden.ts | 27 +++++++-- packages/binden/src/response.ts | 14 ++++- packages/binden/test/binden.test.ts | 58 ++++++++++++++++++- packages/compression/test/compression.test.ts | 21 +++---- 4 files changed, 97 insertions(+), 23 deletions(-) diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index 409feba5..4871adf6 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -286,14 +286,20 @@ export class Binden { Binden.#log(context, level, message, base); }; if (response.writableEnded) { - log("debug", "`.end()` has been called. Destroying the socket"); + // Sent (or being sent): destroying the socket would cut it + log("debug", "`.end()` has been called. Leaving the response"); + } else if (response.started) { + // Part of the body may be sent (or compressed): ending the response would + // make a truncated body look complete + log("debug", "The response has been started. Destroying the socket"); request.destroy(); - } else if (response.headersSent) { - log("debug", "Headers has been sent. Ending the response"); - - response.end(); } else { + // The framing and representation of the body that is not sent + for (const name of Binden.#representation) { + response.removeHeader(name); + } + const errorStatus = Number((error as { status?: unknown }).status); const status = @@ -366,6 +372,17 @@ export class Binden { } } + /** The headers of a body, which do not apply to an error that replaces it */ + static readonly #representation = [ + "Content-Disposition", + "Content-Encoding", + "Content-Length", + "Content-Range", + "ETag", + "Last-Modified", + "Transfer-Encoding", + ]; + /** Framework logs never break the handling of a request (e.g. a failing serializer) */ static #log( context: Context, diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index 1a7a29b5..ad156ad8 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -160,6 +160,7 @@ export class BindenResponse< */ #pipe(data: Readable): Promise { let flushed = 0; + let written = false; const done = this.#finished( () => this.req.method === "HEAD" || @@ -176,7 +177,8 @@ export class BindenResponse< .on("data", (chunk: Uint8Array | string) => { try { const size = Buffer.byteLength(chunk); - const written = this.write(chunk, (error?: Error | null) => { + written = true; + const accepted = this.write(chunk, (error?: Error | null) => { if (error) { // Not delivered: the client went away (Node.js 24 may still // emit `finish` once the response is ended) @@ -186,7 +188,7 @@ export class BindenResponse< flushed += size; } }); - if (!written) { + if (!accepted) { data.pause(); this.once("drain", () => { data.resume(); @@ -200,7 +202,13 @@ export class BindenResponse< .once("end", () => { this.end(); }) - .once("error", reject); + .once("error", (error) => { + // Nothing sent yet: the error can still be replied (see `.started`) + if (!written) { + this.#started = false; + } + reject(error); + }); done.then(resolve, (error: Error) => { data.destroy(); reject(error); diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index cdfa5a5a..b51a8f0a 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -878,14 +878,66 @@ suite("Binden", () => { app.use(new WM(), new EM()); - const response = await fetch(url); + const response = await fetch(url, { signal: AbortSignal.timeout(5000) }); deepStrictEqual(response.status, 201); deepStrictEqual(response.headers.get("Content-Type"), html); - const received = await response.text(); + // The connection is closed: a truncated body must not look complete + await rejects( + response.text(), + (error: Error) => error.name !== "AbortError", + ); + }); - deepStrictEqual(received, message); + test(".errorHandler() (resets the framing of the response)", async () => { + app.use((context: Context) => { + context.response.set({ + "Content-Length": 99, + "Content-Encoding": "gzip", + ETag: `"etag"`, + "Set-Cookie": "kept=1", + }); + throw new BindenError(400, { expose: true, message: "Bad request" }); + }); + + const response = await fetch(url, { signal: AbortSignal.timeout(5000) }); + + deepStrictEqual(response.status, 400); + deepStrictEqual(response.headers.get("Content-Encoding"), null); + deepStrictEqual(response.headers.get("ETag"), null); + deepStrictEqual(response.headers.get("Set-Cookie"), "kept=1"); + deepStrictEqual(await response.text(), "Bad request"); + }); + + test(".errorHandler() (a stream that fails before sending)", async () => { + app.use(async (context: Context) => { + const failing = new Readable({ + read(): void { + this.destroy(new BindenError(503)); + }, + }); + await context.send(failing); + }); + + const response = await fetch(url, { signal: AbortSignal.timeout(5000) }); + + deepStrictEqual(response.status, 503); + }); + + test(".errorHandler() (an ended response is not cut)", async () => { + const size = 32 * 1024 * 1024; + app.use((context: Context) => { + context.response.end(Buffer.alloc(size)); + throw new BindenError(500); + }); + + const response = await fetch(url, { + signal: AbortSignal.timeout(10_000), + }); + + deepStrictEqual(response.status, 200); + deepStrictEqual((await response.arrayBuffer()).byteLength, size); }); test(".errorHandler() (`writableEnded === true`)", async () => { diff --git a/packages/compression/test/compression.test.ts b/packages/compression/test/compression.test.ts index 39503377..3fd36d70 100644 --- a/packages/compression/test/compression.test.ts +++ b/packages/compression/test/compression.test.ts @@ -1,5 +1,5 @@ /* eslint-disable init-declarations, sort-imports */ -import { deepStrictEqual, ok } from "node:assert"; +import { deepStrictEqual, ok, rejects } from "node:assert"; import { randomBytes, randomUUID } from "node:crypto"; import { once } from "node:events"; import { rm, writeFile } from "node:fs/promises"; @@ -538,21 +538,18 @@ describe("Compression", () => { deepStrictEqual(actual, message); }); - it("compresses an error thrown after the first write", async () => { - const message = "invalid request"; + it("cuts the response on an error thrown after the first write", async () => { app.use(new Compression({ format: "gzip" }), (ct: Context) => { ct.response.write("prefix "); - throw new BindenError(400, { expose: true, message }); + throw new BindenError(400, { expose: true, message: "invalid request" }); }); - const response = await request(url); - - deepStrictEqual(response.headers["content-encoding"], "gzip"); - - const body = await response.body.arrayBuffer(); - const actual = (await gunzip(Buffer.from(body))).toString(); - - ok(actual.startsWith("prefix ")); + // The connection is closed (with the headers or before them): a truncated + // body must not look complete + await rejects(async () => { + const response = await request(url); + await response.body.arrayBuffer(); + }); }); it( From bc2f59534f031b6bbb0b9e0c12ebbaa7ef8357c4 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 01:11:36 +0300 Subject: [PATCH 054/675] fix!: trust Forwarded only from trusted proxies in binden `request.protocol` (and so `secure`, `URL` and `context.url`) trusted the leftmost element of `Forwarded`, the one a client controls: any client could claim `proto=https` over plain HTTP. Over TLS, an element without `proto` made the protocol `http:`. The protocol is now the one of the connection, unless the new `trust_proxy` option of `Binden` trusts the proxies in front of the server: all of them (`true`) or the given number of the nearest ones, whose outermost element gives `proto`. An element without (a known) `proto` keeps the protocol of the connection. With `http.createServer()`, a subclass of `BindenRequest` overrides its protected `trust_proxy` getter. BREAKING CHANGE: `request.protocol` and `request.secure` ignore `Forwarded` unless `trust_proxy` is set: an application behind a TLS-terminating proxy needs `new Binden({ trust_proxy: 1 })` (or the number of its proxies). Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 3 +- packages/binden/src/binden.ts | 27 ++++++++++++++-- packages/binden/src/request.ts | 36 ++++++++++++++++++--- packages/binden/test/binden.test.ts | 36 +++++++++++++++++++-- packages/binden/test/request.test.ts | 47 ++++++++++++++++++++++++++-- 5 files changed, 136 insertions(+), 13 deletions(-) diff --git a/packages/binden/README.md b/packages/binden/README.md index 0e693b78..a8cbb237 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -17,6 +17,7 @@ npm install binden - `auto_head` (default `true`) - a `guarded` router that handles `GET` handles `HEAD` as well - `error_handler` - replaces the default reply to an error thrown by a middleware. It may be `async`: it is awaited, and the default reply is sent when it has not started a response. When it fails, the connection is closed. - `logger` - see [Logging](#logging) +- `trust_proxy` (default `false`) - the proxies in front of the server whose [`Forwarded`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded) elements `request.protocol` trusts: none (`false`), all of them (`true`), or the given number of the nearest ones (each proxy appends its element, so the leftmost ones come from the client) - `.use()` - Add a `Middleware/Router` to the stack @@ -396,7 +397,7 @@ const rawHeader = request.header("X-Header"); const { id } = request; ``` -- `.protocol` - respects the [Forwarded](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded) header: +- `.protocol` - the protocol of the connection or, behind trusted proxies (the `trust_proxy` option of `Binden`), the `proto` of the [Forwarded](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded) element of the outermost of them. With `http.createServer()`, override the protected `trust_proxy` getter in a subclass of `BindenRequest`: ```typescript const { protocol } = request; diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index 4871adf6..138b0b65 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -6,7 +6,7 @@ import { Context } from "./context.js"; import { BindenError } from "./error.js"; import defaultLogger from "./logger.js"; import { Middleware, type IMiddleware } from "./middleware.js"; -import { BindenRequest } from "./request.js"; +import { BindenRequest, type ITrustProxy } from "./request.js"; import { BindenResponse, ct_text, ct_json } from "./response.js"; import { Router } from "./router.js"; @@ -25,6 +25,12 @@ export interface IBindenOptions { error_handler?: (context: Context, error: unknown) => Promise | void; /** The logger of the contexts (the default one is set up by `BINDEN_LOG_LEVEL`) */ logger?: Logger; + /** + * The proxies whose `Forwarded` elements `request.protocol` trusts: none + * (`false`, the default), all of them (`true`) or the given number of the + * nearest ones + */ + trust_proxy?: ITrustProxy; } export class Binden { @@ -33,16 +39,31 @@ export class Binden { readonly #error_handler: ((context: Context, error: unknown) => Promise | void) | null; readonly #logger: Logger; + readonly #Request: typeof BindenRequest; public constructor({ error_handler, auto_head = true, logger = defaultLogger, + trust_proxy = false, }: IBindenOptions = {}) { + if ( + typeof trust_proxy === "number" && + !(Number.isSafeInteger(trust_proxy) && trust_proxy >= 0) + ) { + throw new RangeError("`trust_proxy` is not a non-negative integer"); + } this.#stack = []; this.#error_handler = error_handler ?? null; this.#auto_head = auto_head; this.#logger = logger; + // The requests of this application trust its proxies + this.#Request = class extends BindenRequest { + // eslint-disable-next-line class-methods-use-this -- the option of the app + protected override get trust_proxy(): ITrustProxy { + return trust_proxy; + } + }; } public get stack(): IStack[] { @@ -61,7 +82,7 @@ export class Binden { return http.createServer( { ...options, - IncomingMessage: BindenRequest, + IncomingMessage: this.#Request, ServerResponse: BindenResponse, }, (request, response) => { @@ -79,7 +100,7 @@ export class Binden { return https.createServer( { ...options, - IncomingMessage: BindenRequest, + IncomingMessage: this.#Request, ServerResponse: BindenResponse, }, (request, response) => { diff --git a/packages/binden/src/request.ts b/packages/binden/src/request.ts index b6e1fc98..75dcd73a 100644 --- a/packages/binden/src/request.ts +++ b/packages/binden/src/request.ts @@ -14,6 +14,12 @@ import Range from "./headers/range.js"; export type IProtocol = "http:" | "https:"; +/** + * The proxies in front of the server that are trusted: none (`false`), all of + * them (`true`) or the given number of the nearest ones + */ +export type ITrustProxy = boolean | number; + export class BindenRequest extends IncomingMessage { #accept_encoding?: readonly AcceptEncoding[]; #authorization?: Authorization | null; @@ -102,12 +108,32 @@ export class BindenRequest extends IncomingMessage { return this.#if_modified_since; } + /** + * The protocol of the connection or, from trusted proxies (see + * `trust_proxy`), the one of the `Forwarded` element of the outermost of them + */ public get protocol(): IProtocol { - const forwarded = this.forwarded.shift(); - return (!forwarded && this.socket instanceof TLSSocket) || - forwarded?.proto?.toLowerCase() === "https" - ? "https:" - : "http:"; + const tls: IProtocol = + this.socket instanceof TLSSocket ? "https:" : "http:"; + const trust = this.trust_proxy; + const { forwarded } = this; + if (trust === false || trust === 0 || forwarded.length === 0) { + return tls; + } + // Every proxy appends its element: the client controls the leftmost ones + const index = trust === true ? 0 : Math.max(0, forwarded.length - trust); + const proto = forwarded[index]?.proto?.toLowerCase(); + return proto === "https" ? "https:" : proto === "http" ? "http:" : tls; + } + + /** + * The proxies whose `Forwarded` elements are trusted (none by default). + * `Binden` sets it from its `trust_proxy` option; with `http.createServer()`, + * override it in a subclass. + */ + // eslint-disable-next-line class-methods-use-this -- overridden by subclasses + protected get trust_proxy(): ITrustProxy { + return false; } public get query(): ParsedUrlQuery { diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index b51a8f0a..57f1c80c 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -131,17 +131,49 @@ suite("Binden", () => { }); const m = new SendMiddleware(); - newApp.use(m); - const response = await fetch(path.toString(), { agent }); + let protocol = ""; + newApp.use((context: Context) => { + ({ protocol } = context.request); + }, m); + // An untrusted `Forwarded` does not change the protocol of the connection + const headers = { Forwarded: "for=1.2.3.4" }; + const response = await fetch(path.toString(), { agent, headers }); deepStrictEqual(response.status, 200); const data = await response.text(); deepStrictEqual(data, m.id); + deepStrictEqual(protocol, "https:"); await new Promise((resolve) => { newServer.close(resolve); }); }); + test("`trust_proxy`", async () => { + const protocols: string[] = []; + const record = (context: Context): Promise => { + protocols.push(context.request.protocol); + return context.send(); + }; + app.use(record); + const trusting = await loggedApp({ trust_proxy: 1 }); + try { + trusting.app.use(record); + const headers = { Forwarded: "for=1.2.3.4;proto=https" }; + const signal = AbortSignal.timeout(5000); + + await fetch(url, { headers, signal }); + await fetch(trusting.url, { headers, signal }); + + deepStrictEqual(protocols, ["http:", "https:"]); + } finally { + await trusting.close(); + } + + for (const trust_proxy of [-1, 1.5, Number.NaN]) { + throws(() => new Binden({ trust_proxy }), RangeError); + } + }); + test("stack (use/off)", () => { const m1 = new CustomMiddleware(); const m2 = new CustomMiddleware(); diff --git a/packages/binden/test/request.test.ts b/packages/binden/test/request.test.ts index ec2799fe..675f0735 100644 --- a/packages/binden/test/request.test.ts +++ b/packages/binden/test/request.test.ts @@ -1,6 +1,8 @@ /* eslint-disable init-declarations */ import { deepStrictEqual, ok } from "node:assert"; import { Server, createServer } from "node:http"; +import { Socket } from "node:net"; +import { TLSSocket } from "node:tls"; import { stringify, parse } from "node:querystring"; import { afterEach, beforeEach, suite, test } from "node:test"; import fetch from "node-fetch"; @@ -11,6 +13,7 @@ import { Forwarded, Cookie, IfModifiedSince, + type ITrustProxy, } from "../index.js"; const port = 8080; @@ -252,13 +255,53 @@ suite("BindenRequest", () => { await serverPromise; }); + test(".protocol (trusted proxies)", () => { + const create = ( + trust: ITrustProxy, + forwarded?: string, + socket = new Socket(), + ): BindenRequest => { + class Trusting extends BindenRequest { + // eslint-disable-next-line class-methods-use-this -- a fixture + protected override get trust_proxy(): ITrustProxy { + return trust; + } + } + const request = new Trusting(socket); + if (typeof forwarded === "string") { + request.headers = { forwarded }; + } + return request; + }; + // The client sent the first element, the nearest proxy appended the last one + const header = "for=1.2.3.4;proto=https, for=203.0.113.9;proto=http"; + + deepStrictEqual(create(false, header).protocol, "http:"); + deepStrictEqual(create(0, header).protocol, "http:"); + deepStrictEqual(create(1, header).protocol, "http:"); + deepStrictEqual(create(2, header).protocol, "https:"); + deepStrictEqual(create(5, header).protocol, "https:"); + deepStrictEqual(create(true, header).protocol, "https:"); + // Without the header or its `proto`: the protocol of the connection + deepStrictEqual(create(true).protocol, "http:"); + deepStrictEqual(create(true, "for=1.2.3.4").protocol, "http:"); + deepStrictEqual(create(true, "for=1.2.3.4;proto=ftp").protocol, "http:"); + // Over TLS, a trusted element without `proto` keeps `https:` + const tls = new TLSSocket(new Socket()); + deepStrictEqual(create(true, "for=1.2.3.4", tls).protocol, "https:"); + deepStrictEqual(create(true, header, tls).protocol, "https:"); + deepStrictEqual(create(1, header, tls).protocol, "http:"); + tls.destroy(); + }); + test(".forwarded", async () => { const forwarded = new Forwarded({ for: "8:8:8:8", proto: "https" }); const serverPromise = new Promise((resolve, reject) => { server.once("request", (request, response) => { try { - deepStrictEqual(request.protocol, "https:"); - ok(request.secure); + // A client can send the header: proxies are not trusted by default + deepStrictEqual(request.protocol, "http:"); + ok(!request.secure); deepStrictEqual(request.forwarded, [forwarded]); deepStrictEqual(request.forwarded[0].for, forwarded.for); deepStrictEqual(request.forwarded[0].proto, forwarded.proto); From f30b39b7c41e063d608082e7cc264f1ed4e7e4a9 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 01:12:29 +0300 Subject: [PATCH 055/675] fix: keep the prototype of request.query in binden `request.query` assigned every parameter to a new object, so a `__proto__` parameter went through the `__proto__` setter: `?__proto__=a&__proto__=b` made an array the prototype of the query (with `length`, `map` and indexes showing up), and `?__proto__=a` was lost. The parameters are now defined with `Object.fromEntries()`, as the README describes (`{ ...parse(search) }`). Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/request.ts | 15 +++++++-------- packages/binden/test/request.test.ts | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/binden/src/request.ts b/packages/binden/src/request.ts index 75dcd73a..b19304a8 100644 --- a/packages/binden/src/request.ts +++ b/packages/binden/src/request.ts @@ -141,14 +141,13 @@ export class BindenRequest extends IncomingMessage { this.#query = parse(this.URL.search.substring(1)); } - const query: ParsedUrlQuery = {}; - - for (const key in this.#query) { - const value = this.#query[key]; - query[key] = Array.isArray(value) ? [...value] : value; - } - - return query; + // Defined, not assigned: a `__proto__` key is a key, not the prototype + return Object.fromEntries( + Object.entries(this.#query).map(([key, value]) => [ + key, + Array.isArray(value) ? [...value] : value, + ]), + ); } public get range(): Range[] { diff --git a/packages/binden/test/request.test.ts b/packages/binden/test/request.test.ts index 675f0735..e936f2dd 100644 --- a/packages/binden/test/request.test.ts +++ b/packages/binden/test/request.test.ts @@ -255,6 +255,21 @@ suite("BindenRequest", () => { await serverPromise; }); + test(".query (keeps its prototype)", () => { + const request = new BindenRequest(new Socket()); + request.url = "/?__proto__=a&__proto__=b&x=1"; + request.headers = { host: "localhost" }; + const { query } = request; + + deepStrictEqual(Object.getPrototypeOf(query), Object.prototype); + deepStrictEqual( + Object.getOwnPropertyDescriptor(query, "__proto__")?.value, + ["a", "b"], + ); + deepStrictEqual(query.x, "1"); + deepStrictEqual(Object.keys(query).sort(), ["__proto__", "x"]); + }); + test(".protocol (trusted proxies)", () => { const create = ( trust: ITrustProxy, From 03be0ede9a235596522cd907806028eea6443a09 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 01:17:27 +0300 Subject: [PATCH 056/675] fix!: validate cookies in binden `Cookie` accepted any name, value and attribute and pasted them into `Set-Cookie`: a value such as `x; Domain=evil.example` added a `Domain` (widening the cookie to the parent domain), and a `Max-Age`, `Path` or `SameSite` could be injected the same way; `Max-Age=NaN` or `Expires=Invalid Date` were sent as is. `Cookie.fromString()` split only on `"; "` (`a=1;b=2` was one cookie) and cut a value at its second `=` (base64 values were corrupted). The constructor now throws a `TypeError` unless the name is a token and the value consists of cookie-octets (RFC 6265), the `Domain` is a host name, the `Path` has no control characters or `;`, `Max-Age` is an integer, `Expires` a valid date and `SameSite` one of its values. `Cookie.fromString()` splits on `;` and on the first `=`, and skips invalid cookies. BREAKING CHANGE: `new Cookie()` throws a `TypeError` for an invalid name, value or attribute, and `Cookie.fromString()` (so `request.cookies`) skips such cookies. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 2 +- packages/binden/src/headers/cookie.ts | 65 +++++++++++++++------ packages/binden/test/headers/cookie.test.ts | 62 +++++++++++++++++++- 3 files changed, 110 insertions(+), 19 deletions(-) diff --git a/packages/binden/README.md b/packages/binden/README.md index a8cbb237..5792eb9b 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -597,7 +597,7 @@ const type = ContentType.fromString(request.headers["content-type"]); const { content_type } = request; ``` -- [Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie) +- [Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie) - `new Cookie()` throws a `TypeError` for a name, value or attribute that would change the `Set-Cookie` header (e.g. a value with `;`); `Cookie.fromString()` skips such cookies ```typescript import { Cookie } from "binden"; diff --git a/packages/binden/src/headers/cookie.ts b/packages/binden/src/headers/cookie.ts index 94882063..3855ea06 100644 --- a/packages/binden/src/headers/cookie.ts +++ b/packages/binden/src/headers/cookie.ts @@ -10,6 +10,22 @@ export interface ICookie { readonly same_site?: "Lax" | "None" | "Strict"; } +/** A token (RFC 9110, section 5.6.2) */ +const token = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u; + +/** cookie-octets, maybe in double quotes (RFC 6265, section 4.1.1) */ +const cookieValue = + /^(?:"[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*"|[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*)$/u; + +/** Any character but the controls and `;` (RFC 6265, section 4.1.1) */ +const attributeValue = /^[\x20-\x3A\x3C-\x7E]*$/u; + +/** Labels of letters, digits and hyphens (a leading dot is allowed) */ +const domainValue = + /^\.?[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*$/u; + +const sameSite: ReadonlySet = new Set(["Lax", "None", "Strict"]); + export class Cookie implements ICookie { readonly #key: string; readonly #value: string; @@ -32,6 +48,23 @@ export class Cookie implements ICookie { http_only = true, same_site = "Lax", }: ICookie) { + // An invalid name or attribute could add attributes to `Set-Cookie` + if (!token.test(key)) { + throw new TypeError(`Invalid cookie name: ${key}`); + } else if (!cookieValue.test(value)) { + throw new TypeError("Invalid cookie value"); + } else if (max_age !== null && !Number.isSafeInteger(max_age)) { + throw new TypeError(`Invalid cookie Max-Age: ${max_age}`); + } else if (expires instanceof Date && Number.isNaN(expires.getTime())) { + throw new TypeError("Invalid cookie Expires"); + } else if (domain !== null && !domainValue.test(domain)) { + throw new TypeError(`Invalid cookie Domain: ${domain}`); + } else if (path !== null && !attributeValue.test(path)) { + throw new TypeError(`Invalid cookie Path: ${path}`); + } else if (!sameSite.has(same_site)) { + throw new TypeError(`Invalid cookie SameSite: ${same_site}`); + } + this.#key = key; this.#value = value; this.#max_age = max_age; @@ -105,26 +138,24 @@ export class Cookie implements ICookie { return []; } - const cookies = input - .trim() - .split("; ") - .map((e) => e.trim()) - .filter((e) => e); - const output: Cookie[] = []; - for (const cookie of cookies) { - const [key, rawValue = ""] = cookie.split("=", 2).map((a) => a.trim()); - + for (const pair of input.split(";")) { + // The value may contain `=` (e.g. base64) + const index = pair.indexOf("="); + const key = pair.slice(0, Math.max(index, 0)).trim(); + const raw = pair.slice(index + 1).trim(); const value = - rawValue.startsWith(`"`) && rawValue.endsWith(`"`) - ? rawValue.length > 2 - ? rawValue.substring(1, rawValue.length - 1) - : "" - : rawValue; - - if (key && value && !output.find((c) => c.key === key)) { - output.push(new Cookie({ key, value })); + raw.length >= 2 && raw.startsWith(`"`) && raw.endsWith(`"`) + ? raw.slice(1, -1) + : raw; + + if (index > 0 && value && !output.some((c) => c.key === key)) { + try { + output.push(new Cookie({ key, value })); + } catch { + // Not a valid cookie: skipped + } } } diff --git a/packages/binden/test/headers/cookie.test.ts b/packages/binden/test/headers/cookie.test.ts index 62f08d70..276db7e0 100644 --- a/packages/binden/test/headers/cookie.test.ts +++ b/packages/binden/test/headers/cookie.test.ts @@ -1,4 +1,4 @@ -import { deepStrictEqual } from "node:assert"; +import { deepStrictEqual, throws } from "node:assert"; import { suite, test } from "node:test"; import { Cookie } from "../../index.js"; @@ -160,4 +160,64 @@ suite("Cookie", () => { deepStrictEqual(cookie2.same_site, two.same_site); deepStrictEqual(cookie2.toString(), two.toString()); }); + + test("constructor (invalid attributes)", () => { + const invalid: ConstructorParameters[0][] = [ + { key: "", value: "v" }, + { key: "a=b; Max-Age=0; x", value: "v" }, + { key: "a b", value: "v" }, + { key: "k", value: "x; Domain=evil.example; Path=/admin" }, + { key: "k", value: "a\r\nSet-Cookie: x=y" }, + { key: "k", value: "a,b" }, + { key: "k", value: `a"b` }, + { key: "k", value: "v", path: "/; Domain=evil" }, + { key: "k", value: "v", domain: "good.example; SameSite=None" }, + { key: "k", value: "v", domain: "a b" }, + { key: "k", value: "v", max_age: 1.5 }, + { key: "k", value: "v", max_age: Number.NaN }, + { key: "k", value: "v", max_age: Number.POSITIVE_INFINITY }, + { key: "k", value: "v", expires: new Date("invalid") }, + { + key: "k", + value: "v", + same_site: "Lax; Partitioned" as unknown as "Lax", + }, + ]; + for (const options of invalid) { + throws(() => new Cookie(options), TypeError, JSON.stringify(options)); + } + + // Valid values that look unusual + const cookie = new Cookie({ + key: "token", + value: "abc==", + max_age: 0, + domain: ".example.com", + path: "/a b", + }); + deepStrictEqual( + cookie.toString(), + "token=abc==; Max-Age=0; Domain=.example.com; Path=/a b; HttpOnly; SameSite=Lax", + ); + deepStrictEqual( + new Cookie({ key: "q", value: `"quoted"` }).value, + `"quoted"`, + ); + }); + + test(".fromString() (pairs, `=` in values and invalid cookies)", () => { + const parsed = Cookie.fromString( + `a=1;b=2; token=abc==; quoted="q"; bad key=3; empty=; =4; c=x\u0001; a=5`, + ); + + deepStrictEqual( + parsed.map(({ key, value }) => [key, value]), + [ + ["a", "1"], + ["b", "2"], + ["token", "abc=="], + ["quoted", "q"], + ], + ); + }); }); From b1d4efc234caabefe021721dd7681bbf4113e193 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 01:32:29 +0300 Subject: [PATCH 057/675] fix: do not send the body of bodiless responses Responses to `HEAD` and with a 1xx, 204, 205 or 304 status have no body, but `send()` still read a `Readable` to its end (Node.js discarded the chunks), `sendFile()` read the whole file for `HEAD` (and answered a `HEAD` with `Range` with 206), and `@binden/compression` added `Content-Encoding` to a 204 or 304 and wrote compressed bytes. With the `rejectNonStandardBodyWrites` server option, these writes reset the connection. `send()` now writes no body for these responses: a `Readable` is destroyed without being read, and a response to `HEAD` gets the `Content-Length` of the body it would send. `sendFile()` sends only the headers of the whole file to `HEAD`. Compression does not start without a body, and gives a response to `HEAD` the headers of the compressed response only. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 2 +- packages/binden/src/response.ts | 53 +++++++++-- packages/binden/test/response.test.ts | 94 +++++++++++++++++-- packages/compression/README.md | 2 +- packages/compression/index.ts | 21 ++++- packages/compression/test/compression.test.ts | 39 ++++++++ 6 files changed, 188 insertions(+), 23 deletions(-) diff --git a/packages/binden/README.md b/packages/binden/README.md index 5792eb9b..f6a081b5 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -466,7 +466,7 @@ const headers = { await response.status(402).set(headers).send("Payment is required"); ``` -- `.send()` - send data +- `.send()` - send data. A response without a body (to `HEAD`, or with a 1xx, 204, 205 or 304 status) sends none: a `Readable` is destroyed without being read, and a response to `HEAD` gets the `Content-Length` of the body it would send to `GET` ```typescript await response.send( diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index ad156ad8..5b12cb33 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -95,6 +95,13 @@ export class BindenResponse< } if (data instanceof Readable) { + if (this.#bodiless()) { + // Nothing of the source would be sent: it is not read at all + data.destroy(); + const done = this.#finished(() => false); + this.end(); + return done; + } return this.#pipe(data); } @@ -114,12 +121,25 @@ export class BindenResponse< this.setHeader("Content-Type", ct_text); } - if (typeof data === "string" && encoding) { - this.end(data, encoding); - } else if (data instanceof Buffer || typeof data === "string") { - this.end(data); + const body = + data instanceof Buffer || typeof data === "string" + ? data + : data.toString(); + + if (this.#bodiless()) { + // The headers of the body (as for `GET`), but not the body itself + if ( + this.req.method === "HEAD" && + !this.headersSent && + !this.hasHeader("Content-Length") + ) { + this.setHeader("Content-Length", Buffer.byteLength(body, encoding)); + } + this.end(); + } else if (typeof body === "string" && encoding) { + this.end(body, encoding); } else { - this.end(data.toString()); + this.end(body); } return done; @@ -151,20 +171,30 @@ export class BindenResponse< } /** Send a file */ + /** A response without a body: to `HEAD`, or with a 1xx, 204, 205 or 304 status */ + #bodiless(): boolean { + const { statusCode } = this; + return ( + this.req.method === "HEAD" || + (statusCode >= 100 && statusCode < 200) || + statusCode === 204 || + statusCode === 205 || + statusCode === 304 + ); + } + /** * Writes a `Readable` into the response (with backpressure) and settles like * `#finished()`. The client has the whole response (and may close the - * connection) once the `Content-Length` bytes (none for `HEAD`) are flushed to - * the connection, maybe before the source ends. The header is read at the + * connection) once the `Content-Length` bytes are flushed to the connection, + * maybe before the source ends. The header is read at the * close: compression removes it. */ #pipe(data: Readable): Promise { let flushed = 0; let written = false; const done = this.#finished( - () => - this.req.method === "HEAD" || - flushed >= Number(this.getHeader("Content-Length")), + () => flushed >= Number(this.getHeader("Content-Length")), ); return new Promise((resolve, reject) => { this.once("close", () => { @@ -312,6 +342,9 @@ export class BindenResponse< if ((method === "GET" || method === "HEAD") && ims && ims.date >= lm) { return this.status(304).send(); + } else if (method === "HEAD") { + // The headers of the whole file, without reading it + return this.setHeader("Content-Length", size).send(); } const range = diff --git a/packages/binden/test/response.test.ts b/packages/binden/test/response.test.ts index 5292a192..a297ba1d 100644 --- a/packages/binden/test/response.test.ts +++ b/packages/binden/test/response.test.ts @@ -1,9 +1,10 @@ /* eslint-disable init-declarations */ import { deepStrictEqual, ok, throws, rejects } from "node:assert"; import { randomUUID } from "node:crypto"; +import { once } from "node:events"; import { writeFile, rm, mkdir, rmdir, stat } from "node:fs/promises"; import { IncomingMessage, Server, createServer, get } from "node:http"; -import { Socket } from "node:net"; +import { type AddressInfo, Socket } from "node:net"; import { tmpdir } from "node:os"; import fastJSON from "fast-json-stringify"; import fetch from "node-fetch"; @@ -462,13 +463,66 @@ suite("BindenResponse", () => { }); partial.emit("close"); deepStrictEqual(await settle(sent), "ERR_STREAM_PREMATURE_CLOSE"); + }); + + test(".send() (`HEAD`: the headers of the body only)", async () => { + // Node.js rejects any body of a response to `HEAD` with this option + const strict = createServer( + { ServerResponse: BindenResponse, rejectNonStandardBodyWrites: true }, + (_request, response) => { + response.send("hello").catch(() => { + // The test fails on the response instead + }); + }, + ).listen(0); + await once(strict, "listening"); + const { port: local } = strict.address() as AddressInfo; + + try { + const response = await fetch(`http://localhost:${local}`, { + method: "HEAD", + signal: AbortSignal.timeout(5000), + }); - // A `HEAD` response has no body - const head = create("HEAD"); - head.setHeader("Content-Length", 8); - const headSent = head.send(source()); - head.emit("close"); - deepStrictEqual(await settle(headSent), null); + deepStrictEqual(response.status, 200); + deepStrictEqual(response.headers.get("Content-Length"), "5"); + deepStrictEqual(response.headers.get("Content-Type"), ct_text); + deepStrictEqual(await response.text(), ""); + } finally { + strict.closeAllConnections(); + strict.close(); + } + }); + + test(".send() (a `Readable` is not read without a body)", async () => { + const check = async (method: string, status: number): Promise => { + let reads = 0; + const source = new Readable({ + read(): void { + reads += 1; + this.push(null); + }, + }); + const settled = new Promise((resolve) => { + server.once("request", (_request, response) => { + resolve(settle(response.status(status).send(source))); + }); + }); + + const response = await fetch(url, { method }); + + deepStrictEqual(response.status, status); + deepStrictEqual(await response.text(), ""); + deepStrictEqual(await settled, null); + deepStrictEqual(reads, 0, `${method} ${status}`); + ok(source.destroyed); + }; + + // HEAD, 1xx, 204, 205 and 304 responses have no body + await check("HEAD", 200); + await check("GET", 204); + await check("GET", 205); + await check("GET", 304); }); test( @@ -853,6 +907,32 @@ suite("BindenResponse", () => { } }); + test(".sendFile() (HEAD)", async () => { + const check = async (headers: Record): Promise => { + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + response.sendFile(filePath).then(resolve, reject); + }); + }); + + const response = await fetch(url, { method: "HEAD", headers }); + await serverPromise; + + // The headers of the whole file, without a body + deepStrictEqual(response.status, 200); + deepStrictEqual( + response.headers.get("Content-Length"), + `${msg.byteLength}`, + ); + deepStrictEqual(response.headers.get("Content-Range"), null); + deepStrictEqual(await response.text(), ""); + }; + + await check({}); + // A range applies to GET only + await check({ Range: "bytes=0-9" }); + }); + test(".sendFile() (with `URL`)", async () => { const serverPromise = new Promise((resolve, reject) => { server.once("request", (_request, response) => { diff --git a/packages/compression/README.md b/packages/compression/README.md index 855ff03f..9bff719d 100644 --- a/packages/compression/README.md +++ b/packages/compression/README.md @@ -27,4 +27,4 @@ With the default `format: "auto"`, the content coding is chosen from the `Accept A fixed `format` (`br`, `gzip` or `deflate`) compresses every response regardless of the request. -Partial responses (`206 Partial Content`, or any response with a `Content-Range` header, such as `416 Range Not Satisfiable`) are never compressed, because their ranges refer to the uncompressed body. Neither are responses whose headers have already been sent. +Partial responses (`206 Partial Content`, or any response with a `Content-Range` header, such as `416 Range Not Satisfiable`) are never compressed, because their ranges refer to the uncompressed body. Neither are responses without a body (1xx, `204`, `205`, `304`) or whose headers have already been sent. A response to `HEAD` gets the headers of the compressed response, without a body. diff --git a/packages/compression/index.ts b/packages/compression/index.ts index 4d2f4641..62e0b1fc 100644 --- a/packages/compression/index.ts +++ b/packages/compression/index.ts @@ -62,16 +62,27 @@ export class Compression extends Middleware { // Compression starts with the first body chunk. A `Content-Length` of the // uncompressed body (e.g. from `sendFile`) is dropped. Nothing is compressed - // when the headers have already been sent, nor for partial responses: their - // ranges refer to the uncompressed body (RFC 9110, section 14.1.2). + // when the headers have already been sent, nor without a body (1xx, 204, + // 205, 304), nor for partial responses: their ranges refer to the + // uncompressed body (RFC 9110, section 14.1.2). A response to `HEAD` gets + // the headers of the compressed response, but no body. + const head = context.request.method === "HEAD"; let active: boolean | null = null; let ended = false; const activate = (): boolean => { if (active === null) { + const { statusCode } = response; active = !response.headersSent && - response.statusCode !== 206 && + statusCode >= 200 && + statusCode !== 204 && + statusCode !== 205 && + statusCode !== 304 && + statusCode !== 206 && !response.hasHeader("Content-Range"); + if (!active || head) { + compress.destroy(); + } if (active) { const raw = response.getHeader("Content-Encoding"); response.setHeader( @@ -81,6 +92,8 @@ export class Compression extends Middleware { : [...(Array.isArray(raw) ? raw : [`${raw}`]), format], ); response.removeHeader("Content-Length"); + } + if (active && !head) { // Backpressure in both directions: a compressed chunk is acknowledged // once the response has flushed it (so the compressor waits while the // response is full), and `drain` of the compressor is the `drain` @@ -110,7 +123,7 @@ export class Compression extends Middleware { }); } } - return active; + return active && !head; }; response.write = (( diff --git a/packages/compression/test/compression.test.ts b/packages/compression/test/compression.test.ts index 3fd36d70..4ff288e0 100644 --- a/packages/compression/test/compression.test.ts +++ b/packages/compression/test/compression.test.ts @@ -6,6 +6,7 @@ import { rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; import { Readable } from "node:stream"; import { afterEach, beforeEach, describe, it } from "node:test"; import { @@ -505,6 +506,44 @@ describe("Compression", () => { }, ); + it("does not compress a response without a body", async () => { + // Node.js rejects any body of these responses with this option + const strict = new Binden() + .use(new Compression({ format: "gzip" }), (ct: Context) => + ct.status(Number(ct.request.headers["x-status"])).send("ignored"), + ) + .createServer({ rejectNonStandardBodyWrites: true }) + .listen(0); + await once(strict, "listening"); + const local = `http://localhost:${(strict.address() as AddressInfo).port}`; + + try { + for (const status of [204, 304]) { + const response = await request(local, { + headers: { "x-status": `${status}` }, + signal: AbortSignal.timeout(5000), + }); + deepStrictEqual(response.statusCode, status); + ok(!("content-encoding" in response.headers), `${status}`); + deepStrictEqual(await response.body.text(), ""); + } + + // The headers of the compressed response, without a body + const head = await request(local, { + method: "HEAD", + headers: { "x-status": "200" }, + signal: AbortSignal.timeout(5000), + }); + deepStrictEqual(head.statusCode, 200); + deepStrictEqual(head.headers["content-encoding"], "gzip"); + ok(!("content-length" in head.headers)); + deepStrictEqual(await head.body.text(), ""); + } finally { + strict.closeAllConnections(); + strict.close(); + } + }); + it("does not compress `206` without `Content-Range` (multipart/byteranges)", async () => { const expected = "--boundary--"; app.use(new Compression({ format: "gzip" }), (ct: Context) => From 651a8db355ca162735afaaaeed6c7a9a9272409b Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 01:35:06 +0300 Subject: [PATCH 058/675] fix: parse the Authorization scheme case-insensitively in binden `Authorization.fromString()` split the field on single spaces and compared the scheme exactly: a lowercase `bearer` (the scheme is case-insensitive, RFC 9110, section 11.1) was not parsed, two spaces gave empty credentials, and Digest credentials were cut at their first space. The field is now parsed as `auth-scheme [ 1*SP ( token68 / #auth-param ) ]`: the scheme is matched case-insensitively (and reported in its usual casing), and the credentials are the rest of the field, or `null` when there are none. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/headers/authorization.ts | 58 ++++++++++++------- .../binden/test/headers/authorization.test.ts | 31 ++++++++++ 2 files changed, 68 insertions(+), 21 deletions(-) diff --git a/packages/binden/src/headers/authorization.ts b/packages/binden/src/headers/authorization.ts index c1e34dce..5c06231d 100644 --- a/packages/binden/src/headers/authorization.ts +++ b/packages/binden/src/headers/authorization.ts @@ -39,33 +39,49 @@ export class Authorization implements IAuthorization { } public static fromString(input?: string): Authorization | null { - if (typeof input === "undefined" || input.length === 0) { + // auth-scheme [ 1*SP ( token68 / #auth-param ) ] (RFC 9110, section 11.4) + const { scheme = "", rest = "" } = + /^(?[!#$%&'*+.^_`|~\w-]+)(?: +(?.*))?$/u.exec( + input?.trim() ?? "", + )?.groups ?? {}; + // The scheme is case-insensitive (RFC 9110, section 11.1) + const type = Authorization.#types.get(scheme.toLowerCase()); + + if (typeof type === "undefined") { return null; } - const [type, credentials = ""] = input.trim().split(" "); - - return Authorization.isValidType(type) - ? new Authorization({ type, credentials }) - : null; + const credentials = rest.trim(); + return new Authorization({ + type, + credentials: credentials === "" ? null : credentials, + }); } + /** The supported schemes by their lowercase names */ + static readonly #types: ReadonlyMap = new Map( + ( + [ + "AWS4-HMAC-SHA256", + "Basic", + "Bearer", + "Digest", + "HOBA", + "Mutual", + "OAuth", + "SCRAM-SHA-1", + "SCRAM-SHA-256", + "vapid", + ] as const + ).map((type) => [type.toLowerCase(), type]), + ); + + /** Whether the input is a supported scheme, in its usual casing */ public static isValidType(input?: string): input is IAuthenticationType { - switch (input) { - case "Basic": - case "Bearer": - case "Digest": - case "HOBA": - case "Mutual": - case "OAuth": - case "SCRAM-SHA-1": - case "SCRAM-SHA-256": - case "vapid": - case "AWS4-HMAC-SHA256": - return true; - default: - return false; - } + return ( + typeof input === "string" && + Authorization.#types.get(input.toLowerCase()) === input + ); } } diff --git a/packages/binden/test/headers/authorization.test.ts b/packages/binden/test/headers/authorization.test.ts index 0c27f8f4..483b6f1b 100644 --- a/packages/binden/test/headers/authorization.test.ts +++ b/packages/binden/test/headers/authorization.test.ts @@ -32,4 +32,35 @@ suite("Authorization", () => { deepStrictEqual(parsed.credentials, "credentials"); deepStrictEqual(parsed.toString(), input); }); + + test("Authorization.fromString() (RFC 9110, section 11.4)", () => { + const parse = (input: string): [string, string | null] | null => { + const parsed = Authorization.fromString(input); + return parsed ? [parsed.type, parsed.credentials] : null; + }; + + // The scheme is case-insensitive + deepStrictEqual(parse("bearer abc"), ["Bearer", "abc"]); + deepStrictEqual(parse("BASIC x"), ["Basic", "x"]); + // The credentials are the rest of the field + deepStrictEqual(parse("Bearer abc"), ["Bearer", "abc"]); + deepStrictEqual(parse(`Digest username="Mufasa", realm="x"`), [ + "Digest", + `username="Mufasa", realm="x"`, + ]); + deepStrictEqual(parse("Bearer"), ["Bearer", null]); + deepStrictEqual(Authorization.fromString("Bearer")?.toString(), "Bearer"); + // Not a scheme + deepStrictEqual(parse("Bearer\tabc"), null); + deepStrictEqual(parse("Bear er abc"), null); + }); + + test("Authorization.isValidType()", () => { + // A type guard for the usual casing of the schemes + ok(Authorization.isValidType("Bearer")); + ok(Authorization.isValidType("AWS4-HMAC-SHA256")); + ok(!Authorization.isValidType("bearer")); + ok(!Authorization.isValidType("unknown")); + ok(!Authorization.isValidType()); + }); }); From 96c57d2cdf66e137079d5ddd216992586bbe34e5 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 01:39:26 +0300 Subject: [PATCH 059/675] fix: parse every parameter of Content-Type in binden `ContentType.fromString()` only read the first parameter and split it on every `=`: `multipart/form-data; charset=utf-8; boundary=xyz` had no boundary (so `@binden/multipart` skipped the body), a `charset` after another parameter was lost, and a quoted boundary kept its quotes or was cut at its first `=` (`boundary="----=_Part_0"`). Every parameter is now read, split on its first `=`, with case-insensitive names and quoted-string values (RFC 9110, sections 5.6.4 and 5.6.6), and `;` inside a quoted string does not separate parameters. `toString()` quotes a boundary that is not a token. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/headers/content-type.ts | 83 +++++++++++++------ .../binden/test/headers/content-type.test.ts | 60 ++++++++++++++ packages/multipart/test/multipart.test.ts | 29 +++++++ 3 files changed, 145 insertions(+), 27 deletions(-) diff --git a/packages/binden/src/headers/content-type.ts b/packages/binden/src/headers/content-type.ts index e21fb065..2c2869b1 100644 --- a/packages/binden/src/headers/content-type.ts +++ b/packages/binden/src/headers/content-type.ts @@ -37,49 +37,78 @@ export class ContentType implements IContentType { public toString(): string { return typeof this.boundary === "string" - ? `${this.type}; boundary=${this.boundary}` + ? `${this.type}; boundary=${ContentType.#quote(this.boundary)}` : typeof this.charset === "string" && this.charset ? `${this.type}; charset=${this.charset}` : this.type; } + /** `type/subtype *( OWS ";" OWS parameter )` (RFC 9110, section 8.3.1) */ public static fromString(input?: string): ContentType | null { - if (typeof input === "undefined" || input.length === 0) { - return null; - } - - const [rawType, extra = ""] = input.split(";").map((e) => e.trim()); + const [rawType = "", ...rest] = ContentType.#split(input ?? ""); + const type = rawType.trim().toLowerCase(); - if (!rawType) { + if (!type) { return null; } - const type = rawType.toLowerCase(); - - const [key = "", boundary = ""] = extra - .split("=") - .map((e) => e.trim()) - .filter((e) => e); + // Parameter names are case-insensitive (RFC 9110, section 5.6.6) + const parameters = new Map(); + for (const parameter of rest) { + const index = parameter.indexOf("="); + const name = parameter.slice(0, Math.max(index, 0)).trim().toLowerCase(); + if (index > 0 && !parameters.has(name)) { + const value = parameter.slice(index + 1).trim(); + parameters.set(name, ContentType.#unquote(value)); + } + } if (type === "multipart/form-data") { - return key.toLowerCase() !== "boundary" || !boundary - ? null - : new ContentType({ type, boundary }); + const boundary = parameters.get("boundary") ?? ""; + return boundary ? new ContentType({ type, boundary }) : null; } - if (key.toLowerCase() === "charset") { - return new ContentType({ - type, - charset: - boundary.startsWith(`"`) && boundary.endsWith(`"`) - ? boundary.length > 2 - ? boundary.substring(1, boundary.length - 1).toLowerCase() - : "" - : boundary.toLowerCase(), - }); + const charset = parameters.get("charset")?.toLowerCase() ?? null; + return new ContentType({ type, charset }); + } + + /** Splits on the `;` that are not in a quoted string */ + static #split(input: string): string[] { + const parts: string[] = []; + let part = ""; + let quoted = false; + let escaped = false; + for (const char of input) { + if (char === ";" && !quoted) { + parts.push(part); + part = ""; + } else { + part += char; + if (escaped) { + escaped = false; + } else if (quoted && char === "\\") { + escaped = true; + } else if (char === `"`) { + quoted = !quoted; + } + } } + parts.push(part); + return parts; + } + + /** The value of a quoted string (RFC 9110, section 5.6.4) */ + static #unquote(value: string): string { + return value.length >= 2 && value.startsWith(`"`) && value.endsWith(`"`) + ? value.slice(1, -1).replaceAll(/\\(?.)/gu, "$") + : value; + } - return new ContentType({ type }); + /** A token as is, anything else as a quoted string */ + static #quote(value: string): string { + return /^[!#$%&'*+.^_`|~\w-]+$/u.test(value) + ? value + : `"${value.replaceAll(/(?["\\])/gu, "\\$")}"`; } } diff --git a/packages/binden/test/headers/content-type.test.ts b/packages/binden/test/headers/content-type.test.ts index bfb05951..a88bd56f 100644 --- a/packages/binden/test/headers/content-type.test.ts +++ b/packages/binden/test/headers/content-type.test.ts @@ -99,4 +99,64 @@ suite("ContentType", () => { deepStrictEqual(ContentType.fromString(";"), null); deepStrictEqual(ContentType.fromString(` multipart/form-data `), null); }); + + test(".fromString() (parameters, RFC 9110, section 5.6.6)", () => { + const parse = ( + input: string, + ): [string, string | null, string | null] | null => { + const parsed = ContentType.fromString(input); + return parsed ? [parsed.type, parsed.charset, parsed.boundary] : null; + }; + const form = "multipart/form-data"; + + // In any position, with case-insensitive names + deepStrictEqual(parse(`${form}; charset=utf-8; boundary=xyz`), [ + form, + null, + "xyz", + ]); + deepStrictEqual(parse(`${form}; BOUNDARY=xyz`), [form, null, "xyz"]); + deepStrictEqual(parse("text/plain; format=flowed; charset=UTF-8"), [ + "text/plain", + "utf-8", + null, + ]); + // Quoted strings (RFC 9110, section 5.6.4) + deepStrictEqual(parse(`${form}; boundary="----=_Part_0"`), [ + form, + null, + "----=_Part_0", + ]); + deepStrictEqual(parse(`${form}; boundary="a;b"; charset=x`), [ + form, + null, + "a;b", + ]); + deepStrictEqual(parse(`${form}; boundary="a\\"b"`), [form, null, `a"b`]); + // The first of a repeated parameter + deepStrictEqual(parse(`${form}; boundary=one; boundary=two`), [ + form, + null, + "one", + ]); + // No value + deepStrictEqual(parse(`${form}; boundary=; charset=utf-8`), null); + deepStrictEqual(parse(`${form}; boundary`), null); + }); + + test(".toString() (quotes a boundary that is not a token)", () => { + const type = "multipart/form-data"; + deepStrictEqual( + new ContentType({ type, boundary: "----=_Part_0" }).toString(), + `${type}; boundary="----=_Part_0"`, + ); + deepStrictEqual( + new ContentType({ type, boundary: `a"b` }).toString(), + `${type}; boundary="a\\"b"`, + ); + deepStrictEqual( + new ContentType({ type, boundary: "token" }).toString(), + `${type}; boundary=token`, + ); + }); }); diff --git a/packages/multipart/test/multipart.test.ts b/packages/multipart/test/multipart.test.ts index 2a51d787..bdc7d3a3 100644 --- a/packages/multipart/test/multipart.test.ts +++ b/packages/multipart/test/multipart.test.ts @@ -178,6 +178,35 @@ describe("Multipart", () => { deepEqual(data, msg); }); + it("Parses a `boundary` that comes after another parameter", async () => { + const boundary = "binden-boundary"; + const body = [ + `--${boundary}`, + `Content-Disposition: form-data; name="field"`, + "", + "value", + `--${boundary}--`, + "", + ].join("\r\n"); + let field: unknown = null; + app.use(multipart(), async (context: Context): Promise => { + const { body: parsed } = context.request; + field = parsed instanceof FormData ? parsed.get("field") : null; + await context.send(); + }); + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": `multipart/form-data; charset=utf-8; boundary=${boundary}`, + }, + body, + }); + + deepEqual(response.status, 200); + deepEqual(field, "value"); + }); + it("Replies with 415 error when request body is not a valid FormData", async () => { const ct = "multipart/form-data; boundary=----formdata-undici-064882379910"; From 8d9d9885d5751d59969c4f647a6cd6ba74190e24 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 01:43:08 +0300 Subject: [PATCH 060/675] fix!: reject the content codings that binden cannot decode `ContentEncoding.fromString()` compared the codings exactly and dropped the unknown ones: a body sent with `Content-Encoding: GZIP` (codings are case-insensitive) was parsed as is, and one sent with `gzip, foo` was decoded with `gzip` only, although the result was still encoded with `foo`. `@binden/body` then parsed bytes that it could not have decoded. Codings are now case-insensitive and empty list elements are ignored. When a coding is unknown or malformed, `ContentEncoding.fromString()` (and `request.content_encoding`) returns `null`, and `@binden/body` replies with 415. `ContentEncoding` no longer extends `AcceptEncoding`: a content coding has no quality. BREAKING CHANGE: `ContentEncoding.fromString()` and `request.content_encoding` return `null` when a coding is unknown or malformed, and `ContentEncoding` is no longer an `AcceptEncoding`. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 2 +- .../binden/src/headers/content-encoding.ts | 48 +++++++++++++------ packages/binden/src/request.ts | 9 ++-- .../test/headers/content-encoding.test.ts | 27 ++++++----- packages/binden/test/request.test.ts | 23 +++++---- packages/body/index.ts | 6 +++ packages/body/test/index.test.ts | 28 +++++++++++ 7 files changed, 101 insertions(+), 42 deletions(-) diff --git a/packages/binden/README.md b/packages/binden/README.md index f6a081b5..86f83018 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -566,7 +566,7 @@ const credentials = Authorization.fromString(request.headers.authorization); const { authorization } = request; ``` -- [Content-Encoding](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding) +- [Content-Encoding](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding) - the codings in the order to remove them, or `null` when one of them is unknown or malformed (the content cannot be decoded then) ```typescript import { ContentEncoding } from "binden"; diff --git a/packages/binden/src/headers/content-encoding.ts b/packages/binden/src/headers/content-encoding.ts index ca106bbd..47c8f72a 100644 --- a/packages/binden/src/headers/content-encoding.ts +++ b/packages/binden/src/headers/content-encoding.ts @@ -1,37 +1,57 @@ -import AcceptEncoding from "./accept-encoding.js"; - export type IEncodings = "br" | "compress" | "deflate" | "gzip" | "x-gzip"; export interface IContentEncoding { encoding: IEncodings; } -export class ContentEncoding - extends AcceptEncoding - implements IContentEncoding -{ +export class ContentEncoding implements IContentEncoding { + readonly #encoding: IEncodings; + public constructor({ encoding }: IContentEncoding) { - super({ encoding }); + this.#encoding = encoding; } public get encoding(): IEncodings { - return super.encoding as IEncodings; + return this.#encoding; } - public static fromString(input?: string): ContentEncoding[] { + public toString(): string { + return this.#encoding; + } + + /** + * The codings in the order to remove them (the last applied first), or `null` + * when one of them is unknown or malformed: the content cannot be decoded + * then (RFC 9110, section 8.4) + */ + public static fromString(input?: string): ContentEncoding[] | null { if (typeof input === "undefined") { return []; } - const encodings = ["gzip", "x-gzip", "compress", "deflate", "br"]; - - return input + // Codings are case-insensitive, empty list elements are ignored (RFC 9110, + // sections 8.4.1 and 5.6.1) + const codings = input .split(",") - .map((e) => e.trim()) - .filter((e): e is IEncodings => encodings.includes(e)) + .map((e) => e.trim().toLowerCase()) + .filter((e) => e !== ""); + + if (!codings.every((e) => ContentEncoding.#encodings.has(e))) { + return null; + } + + return (codings as IEncodings[]) .map((encoding) => new ContentEncoding({ encoding })) .reverse(); } + + static readonly #encodings: ReadonlySet = new Set([ + "gzip", + "x-gzip", + "compress", + "deflate", + "br", + ]); } export default ContentEncoding; diff --git a/packages/binden/src/request.ts b/packages/binden/src/request.ts index b19304a8..d363629c 100644 --- a/packages/binden/src/request.ts +++ b/packages/binden/src/request.ts @@ -24,7 +24,7 @@ export class BindenRequest extends IncomingMessage { #accept_encoding?: readonly AcceptEncoding[]; #authorization?: Authorization | null; #body?: unknown; - #content_encoding?: readonly ContentEncoding[]; + #content_encoding?: readonly ContentEncoding[] | null; #content_type?: ContentType | null; #cookies?: readonly Cookie[]; #forwarded?: readonly Forwarded[]; @@ -59,13 +59,14 @@ export class BindenRequest extends IncomingMessage { } } - public get content_encoding(): ContentEncoding[] { - if (!this.#content_encoding) { + /** The content codings of the body, or `null` when one of them is unknown */ + public get content_encoding(): ContentEncoding[] | null { + if (typeof this.#content_encoding === "undefined") { this.#content_encoding = ContentEncoding.fromString( this.headers["content-encoding"], ); } - return [...this.#content_encoding]; + return this.#content_encoding === null ? null : [...this.#content_encoding]; } public get content_type(): ContentType | null { diff --git a/packages/binden/test/headers/content-encoding.test.ts b/packages/binden/test/headers/content-encoding.test.ts index f6f4a8f2..b45cd458 100644 --- a/packages/binden/test/headers/content-encoding.test.ts +++ b/packages/binden/test/headers/content-encoding.test.ts @@ -12,19 +12,20 @@ suite("ContentEncoding", () => { }); test("ContentEncoding.fromString()", () => { - deepStrictEqual(ContentEncoding.fromString(), []); - deepStrictEqual(ContentEncoding.fromString(""), []); - const input = " x-gzip , br , deflate , ignored "; - const parsed = ContentEncoding.fromString(input); - deepStrictEqual(parsed.length, 3); + const parse = (input?: string): string[] | null => + ContentEncoding.fromString(input)?.map((e) => e.toString()) ?? null; - deepStrictEqual(parsed[0].encoding, "deflate"); - deepStrictEqual(parsed[0].toString(), "deflate"); - - deepStrictEqual(parsed[1].encoding, "br"); - deepStrictEqual(parsed[1].toString(), "br"); - - deepStrictEqual(parsed[2].encoding, "x-gzip"); - deepStrictEqual(parsed[2].toString(), "x-gzip"); + deepStrictEqual(parse(), []); + deepStrictEqual(parse(""), []); + // In the order to remove them; case-insensitive; empty elements ignored + deepStrictEqual(parse(" x-gzip , BR ,, deflate "), [ + "deflate", + "br", + "x-gzip", + ]); + // An unknown or malformed coding: the content cannot be decoded + for (const input of ["gzip, ignored", "gzip;q=1", "gzip br", "identity"]) { + deepStrictEqual(parse(input), null, input); + } }); }); diff --git a/packages/binden/test/request.test.ts b/packages/binden/test/request.test.ts index e936f2dd..b1c87b77 100644 --- a/packages/binden/test/request.test.ts +++ b/packages/binden/test/request.test.ts @@ -81,21 +81,24 @@ suite("BindenRequest", () => { await serverPromise; }); + test(".content_encoding (an unknown coding)", () => { + const request = new BindenRequest(new Socket()); + request.headers = { "content-encoding": "gzip, foo" }; + + deepStrictEqual(request.content_encoding, null); + // Parsed once + deepStrictEqual(request.content_encoding, null); + }); + test(".content_encoding", async () => { const serverPromise = new Promise((resolve, reject) => { server.once("request", (request, response) => { try { const { content_encoding } = request; - deepStrictEqual(content_encoding.length, 3); - - deepStrictEqual(content_encoding[0].encoding, "x-gzip"); - deepStrictEqual(content_encoding[0].q_value, null); - - deepStrictEqual(content_encoding[1].encoding, "br"); - deepStrictEqual(content_encoding[1].q_value, null); - - deepStrictEqual(content_encoding[2].encoding, "compress"); - deepStrictEqual(content_encoding[2].q_value, null); + deepStrictEqual( + content_encoding?.map(({ encoding }) => encoding), + ["x-gzip", "br", "compress"], + ); } catch (error) { reject(error); } finally { diff --git a/packages/body/index.ts b/packages/body/index.ts index 39d43ff1..e60a8417 100644 --- a/packages/body/index.ts +++ b/packages/body/index.ts @@ -56,6 +56,12 @@ export class BodyParser extends Middleware { return; } + if (content_encoding === null) { + // An unknown (or malformed) coding: the body cannot be decoded + log.debug("Unsupported encoding"); + throw new BindenError(415); + } + const streams: [string, Duplex][] = []; for (const { encoding } of content_encoding) { diff --git a/packages/body/test/index.test.ts b/packages/body/test/index.test.ts index 70c99ba0..b9698c87 100644 --- a/packages/body/test/index.test.ts +++ b/packages/body/test/index.test.ts @@ -6,6 +6,7 @@ import { brotliCompress as brotliCompressAsync, gzip as gzipAsync, deflate as deflateAsync, + gzipSync, type InputType, } from "node:zlib"; import { Ajv as AJV, type JTDDataType } from "ajv/dist/jtd.js"; @@ -268,6 +269,33 @@ suite("BodyParser", () => { deepEqual(text, ""); }); + test("`Content-Encoding` (case-insensitive, unknown codings)", async () => { + let parsed: unknown = null; + app.use(new BodyParser(), async (context: Context): Promise => { + parsed = context.request.body; + await context.send(); + }); + const body = gzipSync("Hello World"); + + const upper = await request(url, { + method: "POST", + body, + headers: { "Content-Type": ct_text, "Content-Encoding": "GZIP" }, + }); + deepEqual(upper.statusCode, 200); + await upper.body.dump(); + deepEqual(parsed, "Hello World"); + + // An unknown coding makes the body undecodable + const unknown = await request(url, { + method: "POST", + body, + headers: { "Content-Type": ct_text, "Content-Encoding": "gzip, foo" }, + }); + deepEqual(unknown.statusCode, 415); + await unknown.body.dump(); + }); + test("Unsupported `Content-Encoding`", async () => { const body = "Hello World"; From 7ba2d4dbe446f4ca1c64b868f82ee0e6b1b541fd Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 01:45:16 +0300 Subject: [PATCH 061/675] fix: parse byte ranges strictly in binden `Range.fromString()` trimmed any whitespace and read the positions with `Number()`: CR/LF or a non-breaking space around the ranges were ignored, hexadecimal, exponent, fractional, signed or `Infinity` positions were accepted (`bytes=0x10-` was `bytes=16-`, `bytes=1.5-2` became a suffix range), `bytes=0-1-2` was cut to `0-1`, and the unit was case-sensitive. The ranges now follow RFC 9110, section 14.1.1: positions are plain digits (safe integers), only spaces and tabs may surround them, the unit is case-insensitive, and an invalid range is skipped. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/headers/range.ts | 48 +++++++++++----------- packages/binden/test/headers/range.test.ts | 27 ++++++++++++ 2 files changed, 50 insertions(+), 25 deletions(-) diff --git a/packages/binden/src/headers/range.ts b/packages/binden/src/headers/range.ts index 28b3bdc4..0acea3ab 100644 --- a/packages/binden/src/headers/range.ts +++ b/packages/binden/src/headers/range.ts @@ -34,36 +34,34 @@ export class Range implements IRange { return `${this.unit}=${this.start ?? ""}-${this.end ?? ""}`; } + /** + * The byte ranges (RFC 9110, section 14.1.1): `first-pos "-" [ last-pos ]` or + * `"-" suffix-length`, of plain digits. Spaces and tabs may surround them, and + * an invalid range is skipped. + */ public static fromString(input?: string): Range[] { - if (typeof input !== "string" || !input) { - return []; - } + // The unit is case-insensitive (RFC 9110, section 14.1) + const ranges = + /^[ \t]*bytes=(?.*)$/iu.exec(input ?? "")?.groups?.ranges ?? ""; + const output: Range[] = []; - const trimmed = input.trim(); + for (const element of ranges.split(",")) { + const { first = "", last = "" } = + /^[ \t]*(?\d*)[ \t]*-[ \t]*(?\d*)[ \t]*$/u.exec(element) + ?.groups ?? {}; + const start = first === "" ? null : Number(first); + const end = last === "" ? null : Number(last); + const valid = + start === null + ? end !== null && end > 0 + : Number.isSafeInteger(start) && (end === null || start <= end); - if (!trimmed.startsWith("bytes=")) { - return []; + if (valid && (end === null || Number.isSafeInteger(end))) { + output.push(new Range({ start, end })); + } } - return trimmed - .substring(6) - .split(",") - .map((e) => e.trim().split("-", 2)) - .map(([start, end]) => [ - start.trim(), - (end as string | undefined)?.trim() ?? "", - ]) - .map(([start, end]) => [ - start ? Number(start) : NaN, - end ? Number(end) : NaN, - ]) - .filter( - ([start, end]) => - (!isNaN(start) && !isNaN(end) && start >= 0 && start <= end) || - (!isNaN(start) && isNaN(end) && start >= 0) || - (isNaN(start) && !isNaN(end) && end > 0), - ) - .map(([start, end]) => new Range({ start, end })); + return output; } } diff --git a/packages/binden/test/headers/range.test.ts b/packages/binden/test/headers/range.test.ts index 98a1d1ef..eebb194e 100644 --- a/packages/binden/test/headers/range.test.ts +++ b/packages/binden/test/headers/range.test.ts @@ -79,4 +79,31 @@ suite("Range", () => { deepStrictEqual(actual.start, range.start); deepStrictEqual(actual.toString(), range.toString()); }); + + test(".fromString() (RFC 9110, section 14.1.1)", () => { + const parse = (input: string): string[] => + Range.fromString(input).map((range) => range.toString()); + + // The unit is case-insensitive + deepStrictEqual(parse("Bytes=0-1"), ["bytes=0-1"]); + deepStrictEqual(parse("bytes=\t0-1 , 5-\t"), ["bytes=0-1", "bytes=5-"]); + // Only plain digits, no control characters + for (const input of [ + "bytes=0-\r\n1", + "bytes=\r\n0-1", + "\r\nbytes=0-1\r\n", + "bytes=\u00a00-\u00a01", + "bytes=0x0-0x10", + "bytes=1e2-", + "bytes=+5-", + "bytes=1.5-2", + "bytes=5-10.5", + "bytes=0-1-2", + "bytes=Infinity-", + "bytes=99999999999999999999-", + "bytes=-99999999999999999999", + ]) { + deepStrictEqual(parse(input), [], JSON.stringify(input)); + } + }); }); From 4683381c91a9ade17a25ec588abe02d9d4933de0 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 01:47:04 +0300 Subject: [PATCH 062/675] fix: accept only qvalues as weights of Accept-Encoding in binden The weight of an `Accept-Encoding` element was read with `Number()`: `gzip;q=2`, `q=Infinity` or `q=0x1` were accepted (so `br;q=1, gzip;q=2` negotiated `gzip` in `@binden/compression`), an empty `q=` made a coding unacceptable and a malformed one (`q=abc`) gave it the weight 1. A weight must now be a qvalue (RFC 9110, section 12.4.2: from 0 to 1, with at most 3 decimals); an element with any other weight is dropped. Co-Authored-By: Claude Opus 5.5 --- .../binden/src/headers/accept-encoding.ts | 21 +++++++++------ .../test/headers/accept-encoding.test.ts | 26 +++++++++++++++++++ packages/compression/test/compression.test.ts | 13 ++++++++++ 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/packages/binden/src/headers/accept-encoding.ts b/packages/binden/src/headers/accept-encoding.ts index b9e7d01d..dc5d563a 100644 --- a/packages/binden/src/headers/accept-encoding.ts +++ b/packages/binden/src/headers/accept-encoding.ts @@ -50,22 +50,27 @@ export class AcceptEncoding implements IAcceptEncoding { // Content codings and the `q` parameter are case-insensitive (RFC 9110) return input .split(",") - .map<[string, number]>((part) => { + .map<[string, number | null] | null>((part) => { const [coding = "", ...params] = part .split(";") .map((param) => param.trim()); const q = params .map((param) => /^q\s*=\s*(?.*)$/iu.exec(param)?.groups?.value) .find((value) => typeof value === "string"); - return [coding.toLowerCase(), Number(q?.trim())]; - }) - .filter((e): e is [IAcceptEncodings, number] => encodings.includes(e[0])) - .map(([encoding, q_value]) => { - if (isNaN(q_value)) { - return new AcceptEncoding({ encoding }); + if (typeof q === "undefined") { + return [coding.toLowerCase(), null]; } - return new AcceptEncoding({ encoding, q_value }); + // A weight is a qvalue (RFC 9110, section 12.4.2), or the element is invalid + const value = q.trim(); + return /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/u.test(value) + ? [coding.toLowerCase(), Number(value)] + : null; }) + .filter( + (e): e is [IAcceptEncodings, number | null] => + e !== null && encodings.includes(e[0]), + ) + .map(([encoding, q_value]) => new AcceptEncoding({ encoding, q_value })) .sort(({ q_value: a }, { q_value: b }) => (b ?? 1) - (a ?? 1)); } } diff --git a/packages/binden/test/headers/accept-encoding.test.ts b/packages/binden/test/headers/accept-encoding.test.ts index 50c79aad..dde7e6f5 100644 --- a/packages/binden/test/headers/accept-encoding.test.ts +++ b/packages/binden/test/headers/accept-encoding.test.ts @@ -72,4 +72,30 @@ suite("AcceptEncoding", () => { ["deflate;q=1", "x-gzip", "gzip;q=0.5", "br;q=0.4"], ); }); + + test("AcceptEncoding.fromString() (qvalues, RFC 9110, section 12.4.2)", () => { + const parse = (input: string): string[] => + AcceptEncoding.fromString(input).map((e) => e.toString()); + + deepStrictEqual(parse("gzip;q=0.123, br;q=1.000, deflate;q=0."), [ + "br;q=1", + "gzip;q=0.123", + "deflate;q=0", + ]); + // A weight out of 0..1 (or with more than 3 decimals) drops the element + deepStrictEqual(parse("br;q=1, gzip;q=2"), ["br;q=1"]); + for (const q of [ + "-1", + "Infinity", + "0x1", + "1e0", + "0.12345", + "1.5", + "1.001", + "", + "abc", + ]) { + deepStrictEqual(parse(`gzip;q=${q}`), [], q); + } + }); }); diff --git a/packages/compression/test/compression.test.ts b/packages/compression/test/compression.test.ts index 4ff288e0..6a74ada8 100644 --- a/packages/compression/test/compression.test.ts +++ b/packages/compression/test/compression.test.ts @@ -310,6 +310,19 @@ describe("Compression", () => { deepStrictEqual(actual, expected); }); + it("auto (a weight that is not a qvalue drops its coding)", async () => { + const expected = "Hello World"; + app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); + + const headers = { "Accept-Encoding": "br;q=1, gzip;q=2" }; + const response = await request(url, { headers }); + + deepStrictEqual(response.headers["content-encoding"], "br"); + + const body = await response.body.arrayBuffer(); + deepStrictEqual((await brotliDecompress(body)).toString(), expected); + }); + it("auto (case-insensitive codings)", async () => { const expected = "Hello World"; app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); From 2f47d632bdc643f09e039ef5dd83f332b5010f27 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 01:49:37 +0300 Subject: [PATCH 063/675] fix: parse If-Modified-Since as an HTTP-date only in binden `IfModifiedSince.fromString()` used `new Date()`, which accepts much more than an HTTP-date (e.g. `3000` was the year 3000, so every file was "not modified") and reads the asctime format as local time. A recipient must ignore a value that is not an HTTP-date (RFC 9110, section 13.1.3). HTTP-dates are now parsed strictly in their three formats (RFC 9110, section 5.6.7): the IMF-fixdate, and the obsolete RFC 850 (with its two-digit year) and asctime formats, in UTC, and an impossible date (e.g. February 30) is not one. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/headers/http-date.ts | 75 +++++++++++++++++++ .../binden/src/headers/if-modified-since.ts | 12 ++- .../binden/test/headers/http-date.test.ts | 53 +++++++++++++ .../test/headers/if-modified-since.test.ts | 2 + 4 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 packages/binden/src/headers/http-date.ts create mode 100644 packages/binden/test/headers/http-date.test.ts diff --git a/packages/binden/src/headers/http-date.ts b/packages/binden/src/headers/http-date.ts new file mode 100644 index 00000000..1eba79c4 --- /dev/null +++ b/packages/binden/src/headers/http-date.ts @@ -0,0 +1,75 @@ +const months = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +/** `Sun, 06 Nov 1994 08:49:37 GMT` */ +const imf = + /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (?\d{2}) (?[A-Z][a-z]{2}) (?\d{4}) (?\d{2}):(?\d{2}):(?\d{2}) GMT$/u; + +/** `Sunday, 06-Nov-94 08:49:37 GMT` (obsolete) */ +const rfc850 = + /^(?:Mon|Tues|Wednes|Thurs|Fri|Satur|Sun)day, (?\d{2})-(?[A-Z][a-z]{2})-(?\d{2}) (?\d{2}):(?\d{2}):(?\d{2}) GMT$/u; + +/** `Sun Nov 6 08:49:37 1994` (obsolete) */ +const asctime = + /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?[A-Z][a-z]{2}) (?[ \d]\d) (?\d{2}):(?\d{2}):(?\d{2}) (?\d{4})$/u; + +/** + * An HTTP-date in any of its three formats (RFC 9110, section 5.6.7), or `null` + * (`Date.parse()` accepts many other strings, and reads asctime as local time) + */ +export function parseHttpDate(input: string, now = Date.now()): Date | null { + const { + date = "", + month = "", + year = "", + hour = "", + minute = "", + second = "", + } = (imf.exec(input) ?? rfc850.exec(input) ?? asctime.exec(input))?.groups ?? + {}; + const index = months.indexOf(month); + + if (index < 0) { + return null; + } + + let fullYear = Number(year); + if (year.length === 2) { + // A two-digit year more than 50 years in the future is in the past + const current = new Date(now).getUTCFullYear(); + fullYear += current - (current % 100); + if (fullYear > current + 50) { + fullYear -= 100; + } + } + + const components = [ + fullYear, + index, + ...[date, hour, minute, second].map(Number), + ]; + const [y = 0, m = 0, d = 0, h = 0, min = 0, s = 0] = components; + const parsed = new Date(Date.UTC(y, m, d, h, min, s)); + + // Out of range components (e.g. February 30, or 24:00:00) do not round trip + return parsed.getUTCFullYear() === y && + parsed.getUTCMonth() === m && + parsed.getUTCDate() === d && + parsed.getUTCHours() === h && + parsed.getUTCMinutes() === min && + parsed.getUTCSeconds() === s + ? parsed + : null; +} diff --git a/packages/binden/src/headers/if-modified-since.ts b/packages/binden/src/headers/if-modified-since.ts index de7b855c..da75ec57 100644 --- a/packages/binden/src/headers/if-modified-since.ts +++ b/packages/binden/src/headers/if-modified-since.ts @@ -1,3 +1,5 @@ +import { parseHttpDate } from "./http-date.js"; + export interface IIfModifiedSince { readonly date: Date; } @@ -17,14 +19,10 @@ export class IfModifiedSince implements IIfModifiedSince { return this.#date.toUTCString(); } + /** An HTTP-date, or `null` (a recipient ignores any other value) */ public static fromString(input?: string): IfModifiedSince | null { - if (typeof input !== "string" || !input) { - return null; - } - - const date = new Date(input.trim()); - - return isNaN(date.getTime()) ? null : new IfModifiedSince({ date }); + const date = parseHttpDate(input?.trim() ?? ""); + return date ? new IfModifiedSince({ date }) : null; } } diff --git a/packages/binden/test/headers/http-date.test.ts b/packages/binden/test/headers/http-date.test.ts new file mode 100644 index 00000000..f6dea5f7 --- /dev/null +++ b/packages/binden/test/headers/http-date.test.ts @@ -0,0 +1,53 @@ +import { deepStrictEqual } from "node:assert"; +import { suite, test } from "node:test"; + +import { parseHttpDate } from "../../src/headers/http-date.js"; + +suite("HTTP-date", () => { + const expected = new Date(Date.UTC(1994, 10, 6, 8, 49, 37)); + + test("the three formats (RFC 9110, section 5.6.7)", () => { + deepStrictEqual(parseHttpDate("Sun, 06 Nov 1994 08:49:37 GMT"), expected); + deepStrictEqual( + parseHttpDate("Sunday, 06-Nov-94 08:49:37 GMT", Date.UTC(2026, 0)), + expected, + ); + // In UTC, not local time + deepStrictEqual(parseHttpDate("Sun Nov 6 08:49:37 1994"), expected); + deepStrictEqual( + parseHttpDate("Thu Nov 17 08:49:37 1994"), + new Date(Date.UTC(1994, 10, 17, 8, 49, 37)), + ); + }); + + test("a two-digit year is at most 50 years in the future", () => { + const now = Date.UTC(2026, 0); + deepStrictEqual( + parseHttpDate("Monday, 06-Nov-76 08:49:37 GMT", now)?.getUTCFullYear(), + 2076, + ); + deepStrictEqual( + parseHttpDate("Monday, 06-Nov-77 08:49:37 GMT", now)?.getUTCFullYear(), + 1977, + ); + }); + + test("anything else", () => { + for (const input of [ + "", + "3000", + "2015-10-21T07:28:00Z", + "Sun, 06 Nov 1994 08:49:37 UTC", + "sun, 06 Nov 1994 08:49:37 GMT", + "Sun, 06 nov 1994 08:49:37 GMT", + "Sun, 06 Foo 1994 08:49:37 GMT", + "Sun, 6 Nov 1994 08:49:37 GMT", + "Sun, 30 Feb 1994 08:49:37 GMT", + "Sun, 06 Nov 1994 24:00:00 GMT", + "Sun, 06 Nov 0099 08:49:37 GMT", + " Sun, 06 Nov 1994 08:49:37 GMT", + ]) { + deepStrictEqual(parseHttpDate(input), null, JSON.stringify(input)); + } + }); +}); diff --git a/packages/binden/test/headers/if-modified-since.test.ts b/packages/binden/test/headers/if-modified-since.test.ts index 9ec75a4d..15a25b26 100644 --- a/packages/binden/test/headers/if-modified-since.test.ts +++ b/packages/binden/test/headers/if-modified-since.test.ts @@ -23,6 +23,8 @@ suite("If-Modified-Since", () => { deepStrictEqual(IfModifiedSince.fromString(), null); deepStrictEqual(IfModifiedSince.fromString(""), null); deepStrictEqual(IfModifiedSince.fromString("notadate"), null); + // Only an HTTP-date (`Date.parse()` reads "3000" as a year) + deepStrictEqual(IfModifiedSince.fromString("3000"), null); const input = " Wed, 21 Oct 2015 07:28:00 GMT "; const parsed = IfModifiedSince.fromString(input); ok(parsed); From ff0a12d0c17550ed8e72b8bbe2331df5741eaf39 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 01:55:04 +0300 Subject: [PATCH 064/675] fix: follow RFC 9110 conditional and range requests in sendFile `sendFile()` took only the first range (`bytes=0-1,5-6` sent `0-1`, and `bytes=99999-,0-1` was 416 although `0-1` was satisfiable), replied 500 to a range of an empty file and ignored the unsatisfiable `bytes=-0`, applied ranges to any method, matched `If-Range` with any date not later than `Last-Modified` (or with an entity-tag, never compared), and replied 304 to `If-Modified-Since` even with `If-None-Match`. A file modified in the future got a `Last-Modified` in the future. Now (RFC 9110, sections 8.8.2.1, 13.1 and 14): - `Last-Modified` is the modification time in seconds, but not later than now; - `If-Modified-Since` (an HTTP-date) is ignored with `If-None-Match`; - ranges apply to `GET` only, and when `If-Range` is exactly the `Last-Modified` or a strong `ETag` of the response; - the satisfiable ranges are kept: overlapping or adjacent ones are merged, several distinct ones get the whole file (`multipart/byteranges` is not supported), and none gets 416 (`bytes */0` for an empty file, which `ContentRange` now allows). Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 10 +- packages/binden/src/headers/content-range.ts | 2 +- packages/binden/src/headers/range.ts | 2 +- packages/binden/src/response.ts | 112 ++++++++++---- .../binden/test/headers/content-range.test.ts | 4 +- packages/binden/test/headers/range.test.ts | 2 + packages/binden/test/response.test.ts | 143 ++++++++++++++++++ 7 files changed, 238 insertions(+), 37 deletions(-) diff --git a/packages/binden/README.md b/packages/binden/README.md index 86f83018..8ffd1228 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -528,12 +528,14 @@ const form = new URLSearchParams([ await response.form(form); ``` -- `.sendFile()` - Send a file. Respects the following headers - - [If-Modified-Since](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since) +- `.sendFile()` - Send a file with its `Last-Modified` (never in the future). Follows [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html) for the following headers + - [If-Modified-Since](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since) - `304 Not Modified` for `GET` and `HEAD`, unless `If-None-Match` is sent - - [Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range) + - [Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range) - for `GET` only: `206 Partial Content` for one range (overlapping or adjacent ranges are merged; several distinct ranges get the whole file), `416 Range Not Satisfiable` when no range can be satisfied - - [If-Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range) + - [If-Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range) - the ranges apply when it is exactly the `Last-Modified`, or the strong `ETag` set on the response + + A response to `HEAD` gets the headers of the whole file, without reading it. ```typescript const path = ""; diff --git a/packages/binden/src/headers/content-range.ts b/packages/binden/src/headers/content-range.ts index 58155f32..43772660 100644 --- a/packages/binden/src/headers/content-range.ts +++ b/packages/binden/src/headers/content-range.ts @@ -18,7 +18,7 @@ export class ContentRange extends Range implements IContentRange { throw new TypeError("`end` is less than zero"); } else if (size !== "*" && !Number.isSafeInteger(size)) { throw new TypeError("`size` is not an integer"); - } else if (size !== "*" && size <= 0) { + } else if (size !== "*" && size < 0) { throw new TypeError("`size` is less than zero"); } else if (typeof end === "number" && size !== "*" && end >= size) { throw new TypeError("`end` is greater than `size`"); diff --git a/packages/binden/src/headers/range.ts b/packages/binden/src/headers/range.ts index 0acea3ab..23dff1f9 100644 --- a/packages/binden/src/headers/range.ts +++ b/packages/binden/src/headers/range.ts @@ -53,7 +53,7 @@ export class Range implements IRange { const end = last === "" ? null : Number(last); const valid = start === null - ? end !== null && end > 0 + ? end !== null : Number.isSafeInteger(start) && (end === null || start <= end); if (valid && (end === null || Number.isSafeInteger(end))) { diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index 5b12cb33..6c002144 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -6,6 +6,7 @@ import { pathToFileURL } from "node:url"; import ContentRange from "./headers/content-range.js"; import Cookie from "./headers/cookie.js"; import IfModifiedSince from "./headers/if-modified-since.js"; +import { parseHttpDate } from "./headers/http-date.js"; import Range from "./headers/range.js"; import { BindenRequest } from "./request.js"; @@ -329,62 +330,113 @@ export class BindenResponse< } const { mtime, size } = stats; - const lm = new Date(mtime.toUTCString()); + // In seconds, as an HTTP-date, and not in the future (RFC 9110, section 8.8.2.1) + const lm = new Date( + Math.floor(Math.min(mtime.getTime(), Date.now()) / 1000) * 1000, + ); this.set({ "Last-Modified": lm.toUTCString(), "Accept-Ranges": "bytes" }); + const { method = "GET", headers } = this.req; const ims = this.req instanceof BindenRequest ? this.req.if_modified_since - : IfModifiedSince.fromString(this.req.headers["if-modified-since"]); - - const { method = "GET" } = this.req; + : IfModifiedSince.fromString(headers["if-modified-since"]); - if ((method === "GET" || method === "HEAD") && ims && ims.date >= lm) { + // If-None-Match takes precedence over If-Modified-Since (RFC 9110, 13.1.3) + if ( + (method === "GET" || method === "HEAD") && + typeof headers["if-none-match"] === "undefined" && + ims && + lm <= ims.date + ) { return this.status(304).send(); } else if (method === "HEAD") { // The headers of the whole file, without reading it return this.setHeader("Content-Length", size).send(); } - const range = - this.req instanceof BindenRequest - ? this.req.range.shift() - : Range.fromString(this.req.headers.range).shift(); - - const ifRange = new Date((this.req.headers["if-range"] ?? "") as string); + // Ranges apply to GET only (RFC 9110, section 14.2) + const ranges = + method !== "GET" || !this.#ifRange(lm) + ? [] + : this.req instanceof BindenRequest + ? this.req.range + : Range.fromString(headers.range); + const range = BindenResponse.#range(ranges, size); - if (!range || ifRange < lm) { + if (range === null) { const stream = createReadStream(url); return this.setHeader("Content-Length", size).send(stream); - } else if (typeof range.start === "number" && range.start >= size) { + } else if (range === "unsatisfiable") { const cr = new ContentRange({ size }).toString(); return this.setHeader("Content-Range", cr).status(416).send(); } - const opts = { start: range.start ?? 0, end: size - 1 }; + const { start, end } = range; + const stream = createReadStream(url, { start, end }); + const cr = new ContentRange({ size, start, end }).toString(); + return this.set({ "Content-Length": end - start + 1, "Content-Range": cr }) + .status(206) + .send(stream); + } - if (typeof range.end === "number") { - if (typeof range.start !== "number") { - const suffix = size - range.end; - opts.start = suffix < 0 ? 0 : suffix; - } else if (range.end < opts.end) { - opts.end = range.end; - } + /** Whether `If-Range`, when sent, matches the file (RFC 9110, section 13.1.5) */ + #ifRange(lm: Date): boolean { + const raw = this.req.headers["if-range"]; + // Sent more than once, it is invalid: it does not match + const value = typeof raw === "string" ? raw.trim() : ""; + if (typeof raw === "undefined") { + return true; + } else if (value.startsWith(`"`) || value.startsWith("W/")) { + // An entity-tag matches a strong `ETag` of the response only + const etag = this.getHeader("ETag"); + return etag === value && !value.startsWith("W/"); } + // An HTTP-date matches the `Last-Modified` exactly + return parseHttpDate(value)?.getTime() === lm.getTime(); + } - const { start, end } = opts; - const cl = end - start + 1; + /** + * The byte range of a file to send: `null` for the whole file (without a + * range, or with several distinct ones: `multipart/byteranges` is not + * supported), or `"unsatisfiable"` (RFC 9110, section 14.1.1) + */ + static #range( + ranges: readonly Range[], + size: number, + ): { start: number; end: number } | "unsatisfiable" | null { + if (ranges.length === 0) { + return null; + } - this.setHeader("Content-Length", cl); + const satisfiable = ranges + .flatMap(({ start, end }): [number, number][] => { + if (start === null) { + // A suffix: the last `end` bytes + return end !== null && end > 0 && size > 0 + ? [[Math.max(0, size - end), size - 1]] + : []; + } + return start < size ? [[start, Math.min(end ?? size, size - 1)]] : []; + }) + .sort(([a], [b]) => a - b); + const [first, ...rest] = satisfiable; - if (cl === size) { - const stream = createReadStream(url); - return this.send(stream); + if (typeof first === "undefined") { + return "unsatisfiable"; } - const stream = createReadStream(url, { start, end }); - const cr = new ContentRange({ size, start, end }).toString(); - return this.setHeader("Content-Range", cr).status(206).send(stream); + // Overlapping or adjacent ranges are one range + const [start] = first; + let [, end] = first; + for (const [nextStart, nextEnd] of rest) { + if (nextStart > end + 1) { + return null; + } + end = Math.max(end, nextEnd); + } + + return start === 0 && end === size - 1 ? null : { start, end }; } } diff --git a/packages/binden/test/headers/content-range.test.ts b/packages/binden/test/headers/content-range.test.ts index 5eb9758f..e96b7923 100644 --- a/packages/binden/test/headers/content-range.test.ts +++ b/packages/binden/test/headers/content-range.test.ts @@ -39,9 +39,11 @@ suite("ContentRange", () => { new TypeError("`size` is not an integer"), ); throws( - () => new ContentRange({ size: 0 }), + () => new ContentRange({ size: -1 }), new TypeError("`size` is less than zero"), ); + // An empty representation (`bytes */0`) + deepStrictEqual(new ContentRange({ size: 0 }).toString(), "bytes */0"); throws( () => new ContentRange({ size, end: size }), new TypeError("`end` is greater than `size`"), diff --git a/packages/binden/test/headers/range.test.ts b/packages/binden/test/headers/range.test.ts index eebb194e..7d93c98d 100644 --- a/packages/binden/test/headers/range.test.ts +++ b/packages/binden/test/headers/range.test.ts @@ -86,6 +86,8 @@ suite("Range", () => { // The unit is case-insensitive deepStrictEqual(parse("Bytes=0-1"), ["bytes=0-1"]); + // Valid, although it cannot be satisfied + deepStrictEqual(parse("bytes=-0"), ["bytes=-0"]); deepStrictEqual(parse("bytes=\t0-1 , 5-\t"), ["bytes=0-1", "bytes=5-"]); // Only plain digits, no control characters for (const input of [ diff --git a/packages/binden/test/response.test.ts b/packages/binden/test/response.test.ts index a297ba1d..f67a4276 100644 --- a/packages/binden/test/response.test.ts +++ b/packages/binden/test/response.test.ts @@ -6,6 +6,7 @@ import { writeFile, rm, mkdir, rmdir, stat } from "node:fs/promises"; import { IncomingMessage, Server, createServer, get } from "node:http"; import { type AddressInfo, Socket } from "node:net"; import { tmpdir } from "node:os"; +import { join } from "node:path"; import fastJSON from "fast-json-stringify"; import fetch from "node-fetch"; import type { Stats } from "node:fs"; @@ -907,6 +908,148 @@ suite("BindenResponse", () => { } }); + test(".sendFile() (RFC 9110, conditional and range requests)", async () => { + const send = async ( + headers: Record, + { method = "GET", path = filePath, etag = "" } = {}, + ): Promise<[number, string | null, string]> => { + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + if (etag) { + response.setHeader("ETag", etag); + } + response.sendFile(path).then(resolve, reject); + }); + }); + const response = await fetch(url, { + method, + headers, + signal: AbortSignal.timeout(5000), + }); + const body = await response.text(); + await serverPromise; + return [response.status, response.headers.get("Content-Range"), body]; + }; + const whole = msg.toString(); + const size = msg.byteLength; + const seconds = Math.floor(file_stats.mtime.getTime() / 1000) * 1000; + const lm = new Date(seconds).toUTCString(); + const range = { Range: "bytes=0-1" }; + const partial = [206, `bytes 0-1/${size}`, whole.slice(0, 2)]; + + // Several ranges: one when they overlap or touch, the whole file otherwise + deepStrictEqual(await send({ Range: "bytes=0-1,2-3" }), [ + 206, + `bytes 0-3/${size}`, + whole.slice(0, 4), + ]); + deepStrictEqual(await send({ Range: "bytes=2-3, 0-4" }), [ + 206, + `bytes 0-4/${size}`, + whole.slice(0, 5), + ]); + deepStrictEqual(await send({ Range: "bytes=0-1,5-6" }), [200, null, whole]); + // Satisfiable when any range is + deepStrictEqual(await send({ Range: "bytes=99999-,0-1" }), partial); + // To the end of the file + deepStrictEqual(await send({ Range: "bytes=30-" }), [ + 206, + `bytes 30-${size - 1}/${size}`, + whole.slice(30), + ]); + deepStrictEqual(await send({ Range: "bytes=-0" }), [ + 416, + `bytes */${size}`, + "", + ]); + // Ranges apply to GET only + deepStrictEqual(await send(range, { method: "POST" }), [200, null, whole]); + + // If-Range: exactly the Last-Modified, or a strong ETag + deepStrictEqual(await send({ ...range, "If-Range": lm }), partial); + const later = new Date(seconds + 1000).toUTCString(); + deepStrictEqual(await send({ ...range, "If-Range": later }), [ + 200, + null, + whole, + ]); + deepStrictEqual(await send({ ...range, "If-Range": "garbage" }), [ + 200, + null, + whole, + ]); + deepStrictEqual(await send({ ...range, "If-Range": `"abc"` }), [ + 200, + null, + whole, + ]); + deepStrictEqual( + await send({ ...range, "If-Range": `"abc"` }, { etag: `"abc"` }), + partial, + ); + deepStrictEqual( + await send({ ...range, "If-Range": `W/"abc"` }, { etag: `W/"abc"` }), + [200, null, whole], + ); + + // If-None-Match takes precedence over If-Modified-Since + deepStrictEqual((await send({ "If-Modified-Since": lm }))[0], 304); + deepStrictEqual( + await send({ "If-Modified-Since": lm, "If-None-Match": `"x"` }), + [200, null, whole], + ); + }); + + test(".sendFile() (an empty file)", async () => { + const empty = join(dirPath, "empty"); + const check = async ( + headers: Record, + status: number, + range: string | null, + ): Promise => { + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + response.sendFile(empty).then(resolve, reject); + }); + }); + const response = await fetch(url, { headers }); + await serverPromise; + deepStrictEqual(response.status, status); + deepStrictEqual(response.headers.get("Content-Range"), range); + deepStrictEqual(await response.text(), ""); + }; + await writeFile(empty, ""); + try { + // No byte range can be satisfied + await check({ Range: "bytes=0-" }, 416, "bytes */0"); + await check({ Range: "bytes=-5" }, 416, "bytes */0"); + await check({}, 200, null); + } finally { + await rm(empty); + } + }); + + test(".sendFile() (a modification date in the future)", async () => { + const mtime = new Date(Date.now() + 3600 * 1000); + const serverPromise = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + const stats = { + isFile: (): boolean => true, + mtime, + size: msg.byteLength, + }; + response.sendFile(filePath, stats).then(resolve, reject); + }); + }); + + const response = await fetch(url); + await serverPromise; + + const lm = Date.parse(response.headers.get("Last-Modified") ?? ""); + ok(lm <= Date.now(), `${lm}`); + deepStrictEqual(await response.text(), msg.toString()); + }); + test(".sendFile() (HEAD)", async () => { const check = async (headers: Record): Promise => { const serverPromise = new Promise((resolve, reject) => { From 9e6916d9ffe4e2cbe13c2fcc4f8d44c61beb7630 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 01:57:14 +0300 Subject: [PATCH 065/675] fix: reply 400 to a request without a valid URL in binden `request.URL` (which routing by path uses) threw a `TypeError` when the `Host` header could not form a URL (e.g. `Host: a b`), so such a request got a 500. It now throws a `BindenError` with the status 400, which the error handler replies with. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/request.ts | 9 ++++++++- packages/binden/test/binden.test.ts | 18 +++++++++++++++++- packages/binden/test/request.test.ts | 14 +++++++++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/binden/src/request.ts b/packages/binden/src/request.ts index d363629c..65097d01 100644 --- a/packages/binden/src/request.ts +++ b/packages/binden/src/request.ts @@ -3,6 +3,7 @@ import { IncomingMessage } from "node:http"; import { parse, type ParsedUrlQuery } from "node:querystring"; import { TLSSocket } from "node:tls"; +import { BindenError } from "./error.js"; import AcceptEncoding from "./headers/accept-encoding.js"; import Authorization from "./headers/authorization.js"; import ContentEncoding from "./headers/content-encoding.js"; @@ -162,9 +163,15 @@ export class BindenRequest extends IncomingMessage { return this.URL.protocol === "https:"; } + /** The URL of the request (a `400 Bad Request` error without a valid one) */ public get URL(): URL { const { protocol, url = "/", headers } = this; const { host = "" } = headers; - return new URL(url, `${protocol}//${host}`); + const base = `${protocol}//${host}`; + // E.g. an invalid `Host` header + if (!URL.canParse(url, base)) { + throw new BindenError(400); + } + return new URL(url, base); } } diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index 57f1c80c..b0d1fa3d 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -2,7 +2,7 @@ import { deepStrictEqual, ok, rejects, throws } from "node:assert"; import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; -import { Server } from "node:http"; +import { Server, request as httpRequest } from "node:http"; import { Agent } from "node:https"; import { Readable } from "node:stream"; import { afterEach, beforeEach, suite, test } from "node:test"; @@ -593,6 +593,22 @@ suite("Binden", () => { deepStrictEqual(text, m1.id); }); + test("400 error (an invalid `Host`)", async () => { + // Routing by path needs the URL + app.use("/", (context: Context) => context.send()); + + const status = await new Promise((resolve, reject) => { + httpRequest(url, { headers: { host: "a b" } }, (response) => { + response.resume(); + resolve(response.statusCode); + }) + .on("error", reject) + .end(); + }); + + deepStrictEqual(status, 400); + }); + test("404 error (context.done === false)", async () => { app.use(new CustomMiddleware()); const response = await fetch(url); diff --git a/packages/binden/test/request.test.ts b/packages/binden/test/request.test.ts index b1c87b77..000708f5 100644 --- a/packages/binden/test/request.test.ts +++ b/packages/binden/test/request.test.ts @@ -1,5 +1,5 @@ /* eslint-disable init-declarations */ -import { deepStrictEqual, ok } from "node:assert"; +import { deepStrictEqual, ok, throws } from "node:assert"; import { Server, createServer } from "node:http"; import { Socket } from "node:net"; import { TLSSocket } from "node:tls"; @@ -9,6 +9,7 @@ import fetch from "node-fetch"; import { Range, + BindenError, BindenRequest, Forwarded, Cookie, @@ -258,6 +259,17 @@ suite("BindenRequest", () => { await serverPromise; }); + test(".URL (an invalid `Host`)", () => { + const request = new BindenRequest(new Socket()); + request.url = "/"; + request.headers = { host: "a b" }; + + throws( + () => request.URL, + (error: unknown) => error instanceof BindenError && error.status === 400, + ); + }); + test(".query (keeps its prototype)", () => { const request = new BindenRequest(new Socket()); request.url = "/?__proto__=a&__proto__=b&x=1"; From c20e85c16195eb7a8caaa9d76b2dbbd796babed2 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 02:00:32 +0300 Subject: [PATCH 066/675] test: listen on ephemeral ports Every test server listened on port 8080 (and 8081): a local service on these ports, or a second run of the tests, made the tests fail. The servers now listen on a port chosen by the system, read from `server.address()`, and the files of the `BindenResponse` tests are in a new temporary directory for every test instead of fixed names in the temporary directory. The `test/setup.ts` files explain why the requests still use fresh connections. Co-Authored-By: Claude Opus 5.5 --- packages/binden/test/binden.test.ts | 36 +++++++++++-------- packages/binden/test/context.test.ts | 14 ++++++-- packages/binden/test/request.test.ts | 18 ++++++---- packages/binden/test/response.test.ts | 29 +++++++++------ packages/binden/test/setup.ts | 7 ++-- packages/body/test/abort.test.ts | 8 +++-- packages/body/test/index.test.ts | 9 +++-- packages/body/test/logging.test.ts | 6 ++-- packages/body/test/setup.ts | 8 ++--- packages/compression/test/compression.test.ts | 9 +++-- packages/compression/test/setup.ts | 8 ++--- packages/multipart/test/malformed.test.ts | 14 +++++--- packages/multipart/test/multipart.test.ts | 9 +++-- packages/multipart/test/setup.ts | 8 ++--- 14 files changed, 112 insertions(+), 71 deletions(-) diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index b0d1fa3d..ba780956 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -3,6 +3,7 @@ import { deepStrictEqual, ok, rejects, throws } from "node:assert"; import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import { Server, request as httpRequest } from "node:http"; +import type { AddressInfo } from "node:net"; import { Agent } from "node:https"; import { Readable } from "node:stream"; import { afterEach, beforeEach, suite, test } from "node:test"; @@ -59,8 +60,12 @@ class ErrorMiddleware extends CustomMiddleware { } } -const port = 8080; -const url = `http://localhost:${port}`; +/** The port of a listening server */ +function portOf(server: { address(): unknown }): number { + return (server.address() as AddressInfo).port; +} + +let url = ""; interface ILoggedApp { app: Binden; @@ -70,7 +75,7 @@ interface ILoggedApp { close(): Promise; } -/** An application (on `port + 1`) whose debug logs are captured */ +/** An application (on another port) whose debug logs are captured */ async function loggedApp( options: IBindenOptions = {}, logSerializers: LoggerOptions["serializers"] = serializers, @@ -88,10 +93,10 @@ async function loggedApp( }); const logged = new Binden({ ...options, logger }); const loggedServer = logged.createServer(); - const loggedUrl = `http://localhost:${port + 1}`; await new Promise((resolve) => { - loggedServer.listen(port + 1, resolve); + loggedServer.listen(0, resolve); }); + const loggedUrl = `http://localhost:${portOf(loggedServer)}`; return { app: logged, url: loggedUrl, @@ -111,14 +116,16 @@ suite("Binden", () => { beforeEach((_t, done) => { app = new Binden(); - server = app.createServer().listen(port, done); + server = app.createServer().listen(0, () => { + url = `http://localhost:${portOf(server)}`; + done(); + }); }); test(".createSecureServer()", async () => { const newApp = new Binden(); const path = new URL(url); const agent = new Agent({ rejectUnauthorized: false }); - path.port = `${Number(path.port) + 1}`; path.protocol = "https:"; const key = await readFile("./test/cert/key.pem", { encoding: "utf-8" }); @@ -127,8 +134,9 @@ suite("Binden", () => { const newServer = newApp.createSecureServer({ key, cert }); await new Promise((resolve) => { - newServer.listen(port + 1, resolve); + newServer.listen(0, resolve); }); + path.port = `${portOf(newServer)}`; const m = new SendMiddleware(); let protocol = ""; @@ -295,13 +303,13 @@ suite("Binden", () => { const logger = new Logger({ destination, level: "debug", serializers }); const newApp = new Binden({ logger }); const path = new URL(url); - path.port = `${Number(path.port) + 1}`; const newServer = newApp.createServer(); await new Promise((resolve) => { - newServer.listen(port + 1, resolve); + newServer.listen(0, resolve); }); + path.port = `${portOf(newServer)}`; try { let id = ""; @@ -705,13 +713,13 @@ suite("Binden", () => { test("405 error (`guarded` router `auto_head === false`)", async () => { const newApp = new Binden({ auto_head: false }); const path = new URL(url); - path.port = `${Number(path.port) + 1}`; const newServer = newApp.createServer(); await new Promise((resolve) => { - newServer.listen(port + 1, resolve); + newServer.listen(0, resolve); }); + path.port = `${portOf(newServer)}`; const m = new CustomMiddleware(); const router = new Router({ guarded: true }).patch(m).get(m); @@ -735,13 +743,13 @@ suite("Binden", () => { } const newApp = new Binden({ error_handler }); const path = new URL(url); - path.port = `${Number(path.port) + 1}`; const newServer = newApp.createServer(); await new Promise((resolve) => { - newServer.listen(port + 1, resolve); + newServer.listen(0, resolve); }); + path.port = `${portOf(newServer)}`; const status = 405; const message = "Hello World!"; diff --git a/packages/binden/test/context.test.ts b/packages/binden/test/context.test.ts index 289326a2..32b351ef 100644 --- a/packages/binden/test/context.test.ts +++ b/packages/binden/test/context.test.ts @@ -1,6 +1,7 @@ /* eslint-disable init-declarations, @typescript-eslint/no-loop-func */ import { ok, deepStrictEqual } from "node:assert"; import { Server, createServer } from "node:http"; +import type { AddressInfo } from "node:net"; import { afterEach, beforeEach, suite, test } from "node:test"; import fastJSON from "fast-json-stringify"; import fetch from "node-fetch"; @@ -13,8 +14,12 @@ import { BindenResponse, } from "../index.js"; -const port = 8080; -const url = `http://localhost:${port}`; +/** The port of a listening server */ +function portOf(server: { address(): unknown }): number { + return (server.address() as AddressInfo).port; +} + +let url = ""; suite("Context", () => { let server: Server; @@ -23,7 +28,10 @@ suite("Context", () => { server = createServer({ IncomingMessage: BindenRequest, ServerResponse: BindenResponse, - }).listen(port, done); + }).listen(0, () => { + url = `http://localhost:${portOf(server)}`; + done(); + }); }); test("constructor", async () => { diff --git a/packages/binden/test/request.test.ts b/packages/binden/test/request.test.ts index 000708f5..ab893b12 100644 --- a/packages/binden/test/request.test.ts +++ b/packages/binden/test/request.test.ts @@ -1,7 +1,7 @@ /* eslint-disable init-declarations */ import { deepStrictEqual, ok, throws } from "node:assert"; import { Server, createServer } from "node:http"; -import { Socket } from "node:net"; +import { type AddressInfo, Socket } from "node:net"; import { TLSSocket } from "node:tls"; import { stringify, parse } from "node:querystring"; import { afterEach, beforeEach, suite, test } from "node:test"; @@ -17,17 +17,21 @@ import { type ITrustProxy, } from "../index.js"; -const port = 8080; -const url = `http://localhost:${port}`; +/** The port of a listening server */ +function portOf(server: { address(): unknown }): number { + return (server.address() as AddressInfo).port; +} + +let url = ""; suite("BindenRequest", () => { let server: Server; beforeEach((_t, done) => { - server = createServer({ IncomingMessage: BindenRequest }).listen( - port, - done, - ); + server = createServer({ IncomingMessage: BindenRequest }).listen(0, () => { + url = `http://localhost:${portOf(server)}`; + done(); + }); }); test(".accept_encoding", async () => { diff --git a/packages/binden/test/response.test.ts b/packages/binden/test/response.test.ts index f67a4276..457d11ad 100644 --- a/packages/binden/test/response.test.ts +++ b/packages/binden/test/response.test.ts @@ -2,7 +2,7 @@ import { deepStrictEqual, ok, throws, rejects } from "node:assert"; import { randomUUID } from "node:crypto"; import { once } from "node:events"; -import { writeFile, rm, mkdir, rmdir, stat } from "node:fs/promises"; +import { writeFile, rm, mkdir, mkdtemp, stat } from "node:fs/promises"; import { IncomingMessage, Server, createServer, get } from "node:http"; import { type AddressInfo, Socket } from "node:net"; import { tmpdir } from "node:os"; @@ -23,8 +23,12 @@ import { ct_html, } from "../index.js"; -const port = 8080; -const url = `http://localhost:${port}`; +/** The port of a listening server */ +function portOf(server: { address(): unknown }): number { + return (server.address() as AddressInfo).port; +} + +let url = ""; /** Requests `url` and goes away after the first chunk of the response body */ function abortAfterFirstChunk(): Promise { @@ -52,8 +56,10 @@ function settle(promise: Promise): Promise { } suite("BindenResponse", () => { - const filePath = `${tmpdir()}/__binden.test.file`; - const dirPath = `${tmpdir()}/__binden.test.dir`; + // A new directory for every test + let root: string; + let filePath: string; + let dirPath: string; let msg: Buffer; let server: Server; let file_stats: Stats; @@ -61,12 +67,16 @@ suite("BindenResponse", () => { beforeEach(async () => { msg = Buffer.from(randomUUID()); + root = await mkdtemp(join(tmpdir(), "binden-response-")); + filePath = join(root, "file"); + dirPath = join(root, "dir"); await mkdir(dirPath); await writeFile(filePath, msg); await new Promise((resolve) => { server = createServer({ ServerResponse: BindenResponse, - }).listen(port, () => { + }).listen(0, () => { + url = `http://localhost:${portOf(server)}`; resolve(); }); }); @@ -871,7 +881,7 @@ suite("BindenResponse", () => { }); await new Promise((resolve) => { - newServer.listen(port + 1, resolve); + newServer.listen(0, resolve); }); const end = 100; @@ -885,7 +895,7 @@ suite("BindenResponse", () => { }); const headers = { Range: `bytes= - ${end} ` }; try { - const response = await fetch(`http://localhost:${port + 1}`, { + const response = await fetch(`http://localhost:${portOf(newServer)}`, { headers, }); await serverPromise; @@ -1123,8 +1133,7 @@ suite("BindenResponse", () => { }); afterEach(async () => { - await rmdir(dirPath); - await rm(filePath); + await rm(root, { recursive: true, force: true }); server.closeAllConnections(); await new Promise((resolve, reject) => { server.close((error) => { diff --git a/packages/binden/test/setup.ts b/packages/binden/test/setup.ts index 5f3bc5e4..2898638f 100644 --- a/packages/binden/test/setup.ts +++ b/packages/binden/test/setup.ts @@ -1,7 +1,6 @@ import http from "node:http"; -// The suites start a fresh server on the same port before every test. node:test runs -// the next test without yielding to the I/O phase in between, so a keep-alive socket -// of the global agent can be reused after the previous server closed it (ECONNRESET). -// mocha yielded between tests, which hid this race; fresh connections avoid it. +// Every request gets a fresh connection, which the client closes after the response: +// some tests depend on it (e.g. a response that is complete once the client has its +// body closes early), and a pooled keep-alive socket would outlive its test. http.globalAgent = new http.Agent({ keepAlive: false }); diff --git a/packages/body/test/abort.test.ts b/packages/body/test/abort.test.ts index 683e64b8..8729442f 100644 --- a/packages/body/test/abort.test.ts +++ b/packages/body/test/abort.test.ts @@ -1,13 +1,14 @@ /* eslint-disable init-declarations */ import { ok } from "node:assert/strict"; import { type Server, request } from "node:http"; +import type { AddressInfo } from "node:net"; import { afterEach, beforeEach, suite, test } from "node:test"; import { gzipSync } from "node:zlib"; import { Binden, type Context, ct_text } from "binden"; import { BodyParser } from "../index.js"; -const port = 8080; +let port = 0; suite("BodyParser (aborted request)", () => { let server: Server; @@ -35,7 +36,10 @@ suite("BodyParser (aborted request)", () => { } }); - server = app.createServer().listen(port, done); + server = app.createServer().listen(0, () => { + ({ port } = server.address() as AddressInfo); + done(); + }); }); test( diff --git a/packages/body/test/index.test.ts b/packages/body/test/index.test.ts index b9698c87..03c568f7 100644 --- a/packages/body/test/index.test.ts +++ b/packages/body/test/index.test.ts @@ -1,6 +1,7 @@ /* eslint-disable init-declarations */ import { deepEqual, ok, fail, throws } from "node:assert/strict"; import { Server } from "node:http"; +import type { AddressInfo } from "node:net"; import { afterEach, beforeEach, suite, test } from "node:test"; import { brotliCompress as brotliCompressAsync, @@ -51,8 +52,7 @@ function deflate(data: InputType): Promise { }); } -const port = 8080; -const url = `http://localhost:${port}/`; +let url = ""; suite("BodyParser", () => { let app: Binden; @@ -60,7 +60,10 @@ suite("BodyParser", () => { beforeEach((_t, done) => { app = new Binden(); - server = app.createServer().listen(port, done); + server = app.createServer().listen(0, () => { + url = `http://localhost:${(server.address() as AddressInfo).port}/`; + done(); + }); }); test("constructor (with an invalid `parse` option)", () => { diff --git a/packages/body/test/logging.test.ts b/packages/body/test/logging.test.ts index b485fda3..0fc43929 100644 --- a/packages/body/test/logging.test.ts +++ b/packages/body/test/logging.test.ts @@ -1,5 +1,6 @@ import { deepStrictEqual, ok } from "node:assert/strict"; import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; import { suite, test } from "node:test"; import { Logger } from "@binden/logger"; import { Binden, ct_json, serializers } from "binden"; @@ -7,8 +8,6 @@ import { request } from "undici"; import { BodyParser } from "../index.js"; -const port = 8080; -const url = `http://localhost:${port}`; // Invalid JSON: the error of `JSON.parse()` quotes the body const body = '{"password": SECRET}'; @@ -25,8 +24,9 @@ async function post( const app = new Binden({ logger: logger(destination) }).use(new BodyParser()); const server: Server = app.createServer(); await new Promise((resolve) => { - server.listen(port, resolve); + server.listen(0, resolve); }); + const url = `http://localhost:${(server.address() as AddressInfo).port}`; try { const response = await request(url, { diff --git a/packages/body/test/setup.ts b/packages/body/test/setup.ts index 17d6d180..60e04105 100644 --- a/packages/body/test/setup.ts +++ b/packages/body/test/setup.ts @@ -1,8 +1,6 @@ import { Agent, setGlobalDispatcher } from "undici"; -// The suite starts a fresh server on the same port before every test. node:test runs -// the next test without yielding to the I/O phase in between, so undici can reuse a -// pooled keep-alive connection to the previous (already closed) server: the request -// then fails before reaching the new server ("other side closed") and the test hangs. -// mocha yielded between tests, which hid this race; `pipelining: 0` disables keep-alive. +// Every request gets a fresh connection, which the client closes after the response: +// some tests depend on it (e.g. a response that is complete once the client has its +// body closes early), and a pooled keep-alive socket would outlive its test. setGlobalDispatcher(new Agent({ pipelining: 0 })); diff --git a/packages/compression/test/compression.test.ts b/packages/compression/test/compression.test.ts index 6a74ada8..5b4468b2 100644 --- a/packages/compression/test/compression.test.ts +++ b/packages/compression/test/compression.test.ts @@ -61,8 +61,7 @@ function inflate(data: InputType): Promise { }); } -const port = 8080; -const url = new URL(`http://localhost:${port}`); +let url: URL; class TestMiddleware extends Middleware { readonly #data: Buffer | string | undefined; @@ -83,7 +82,11 @@ describe("Compression", () => { beforeEach(async () => { await new Promise((resolve) => { app = new Binden(); - server = app.createServer().listen(port, resolve); + server = app.createServer().listen(0, () => { + const { port } = server.address() as AddressInfo; + url = new URL(`http://localhost:${port}`); + resolve(); + }); }); }); diff --git a/packages/compression/test/setup.ts b/packages/compression/test/setup.ts index 17d6d180..60e04105 100644 --- a/packages/compression/test/setup.ts +++ b/packages/compression/test/setup.ts @@ -1,8 +1,6 @@ import { Agent, setGlobalDispatcher } from "undici"; -// The suite starts a fresh server on the same port before every test. node:test runs -// the next test without yielding to the I/O phase in between, so undici can reuse a -// pooled keep-alive connection to the previous (already closed) server: the request -// then fails before reaching the new server ("other side closed") and the test hangs. -// mocha yielded between tests, which hid this race; `pipelining: 0` disables keep-alive. +// Every request gets a fresh connection, which the client closes after the response: +// some tests depend on it (e.g. a response that is complete once the client has its +// body closes early), and a pooled keep-alive socket would outlive its test. setGlobalDispatcher(new Agent({ pipelining: 0 })); diff --git a/packages/multipart/test/malformed.test.ts b/packages/multipart/test/malformed.test.ts index c0f72d0b..22946696 100644 --- a/packages/multipart/test/malformed.test.ts +++ b/packages/multipart/test/malformed.test.ts @@ -3,10 +3,10 @@ import { Binden, type Context } from "binden"; import { type Server, request } from "node:http"; import { afterEach, beforeEach, describe, it } from "node:test"; import { deepEqual, ok } from "node:assert/strict"; +import type { AddressInfo } from "node:net"; import { Multipart } from "../index.js"; -const port = 8080; -const url = `http://localhost:${port}/`; +let url = ""; function post(body: string, boundary: string): Promise { return new Promise((resolve, reject) => { @@ -34,7 +34,10 @@ describe("Multipart (malformed body)", () => { ct.send("OK"), ); await new Promise((resolve) => { - server = app.createServer().listen(port, resolve); + server = app.createServer().listen(0, () => { + url = `http://localhost:${(server.address() as AddressInfo).port}/`; + resolve(); + }); }); }); @@ -103,7 +106,10 @@ describe("Multipart (aborted request)", () => { } }); await new Promise((resolve) => { - server = app.createServer().listen(port, resolve); + server = app.createServer().listen(0, () => { + url = `http://localhost:${(server.address() as AddressInfo).port}/`; + resolve(); + }); }); }); diff --git a/packages/multipart/test/multipart.test.ts b/packages/multipart/test/multipart.test.ts index bdc7d3a3..4f8e7d21 100644 --- a/packages/multipart/test/multipart.test.ts +++ b/packages/multipart/test/multipart.test.ts @@ -1,12 +1,12 @@ /* eslint-disable init-declarations, sort-imports */ import { deepEqual, ok } from "node:assert/strict"; import { request, Server } from "node:http"; +import type { AddressInfo } from "node:net"; import { afterEach, beforeEach, describe, it } from "node:test"; import { Binden, Context } from "binden"; import multipart, { Multipart } from "../index.js"; -const port = 8080; -const url = `http://localhost:${port}/`; +let url = ""; describe("Multipart", () => { let app: Binden; @@ -15,7 +15,10 @@ describe("Multipart", () => { beforeEach(async () => { app = new Binden(); await new Promise((resolve) => { - server = app.createServer().listen(port, resolve); + server = app.createServer().listen(0, () => { + url = `http://localhost:${(server.address() as AddressInfo).port}/`; + resolve(); + }); }); }); diff --git a/packages/multipart/test/setup.ts b/packages/multipart/test/setup.ts index bd04fa70..cc981f01 100644 --- a/packages/multipart/test/setup.ts +++ b/packages/multipart/test/setup.ts @@ -1,10 +1,8 @@ import { Agent, setGlobalDispatcher } from "undici"; import http from "node:http"; -// The suite starts a fresh server on the same port before every test. node:test runs -// the next test without yielding to the I/O phase in between, so a pooled keep-alive -// connection to the previous (already closed) server could be reused. Disable keep-alive -// for `node:http` requests and for `fetch` (the undici global dispatcher is shared with -// the built-in `fetch`). +// Every request gets a fresh connection, which the client closes after the response: +// some tests depend on it (e.g. a response that is complete once the client has its +// body closes early), and a pooled keep-alive socket would outlive its test. http.globalAgent = new http.Agent({ keepAlive: false }); setGlobalDispatcher(new Agent({ pipelining: 0 })); From cf827c1027ac19da30631243fce0700da68dcf34 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 02:06:16 +0300 Subject: [PATCH 067/675] build: make the tooling portable to Windows - The `prebuild` scripts remove `dist` with `scripts/clean.mjs` instead of `rm -rf`. - `scripts/npm.mjs` runs npm with Node.js: the CLI of `npm_execpath` (set by `npm run` and `npx`), or on Windows the one next to Node.js, where `npm` is `npm.cmd` and `execFile()` cannot run it without a shell. `release.mjs` and `consumer-check.mjs` use it. - The release tests pass their fake npm as `npm_execpath`, instead of an executable with a shebang in a `PATH` joined with `:`; the test runner fixture links `node_modules` with a junction, which does not need the symbolic link privilege on Windows. - The README examples are found with CRLF line endings too, and `.gitattributes` checks out every text file with LF. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 1 + packages/binden/package.json | 2 +- packages/body/package.json | 2 +- packages/compression/package.json | 2 +- packages/logger/package.json | 2 +- packages/multipart/package.json | 2 +- scripts/clean.mjs | 6 ++++ scripts/consumer-check.mjs | 12 +++++-- scripts/npm.mjs | 30 +++++++++++++++++ scripts/npm.test.mjs | 53 +++++++++++++++++++++++++++++++ scripts/release.mjs | 4 +-- scripts/release.test.mjs | 27 ++++++++-------- scripts/test.test.mjs | 7 +++- 13 files changed, 126 insertions(+), 24 deletions(-) create mode 100644 .gitattributes create mode 100644 scripts/clean.mjs create mode 100644 scripts/npm.mjs create mode 100644 scripts/npm.test.mjs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..6313b56c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/packages/binden/package.json b/packages/binden/package.json index 893dade9..4393d524 100644 --- a/packages/binden/package.json +++ b/packages/binden/package.json @@ -15,7 +15,7 @@ }, "scripts": { "build": "tsc", - "prebuild": "rm -rf dist", + "prebuild": "node ../../scripts/clean.mjs", "prepack": "npm run build", "test": "node ../../scripts/test.mjs" }, diff --git a/packages/body/package.json b/packages/body/package.json index f7d36388..be2a7d38 100644 --- a/packages/body/package.json +++ b/packages/body/package.json @@ -15,7 +15,7 @@ }, "scripts": { "build": "tsc", - "prebuild": "rm -rf dist", + "prebuild": "node ../../scripts/clean.mjs", "prepack": "npm run build", "test": "node ../../scripts/test.mjs" }, diff --git a/packages/compression/package.json b/packages/compression/package.json index 3e265f36..48d2a12c 100644 --- a/packages/compression/package.json +++ b/packages/compression/package.json @@ -15,7 +15,7 @@ }, "scripts": { "build": "tsc", - "prebuild": "rm -rf dist", + "prebuild": "node ../../scripts/clean.mjs", "prepack": "npm run build", "test": "node ../../scripts/test.mjs" }, diff --git a/packages/logger/package.json b/packages/logger/package.json index 7a7ac125..3c72f537 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -15,7 +15,7 @@ }, "scripts": { "build": "tsc", - "prebuild": "rm -rf dist", + "prebuild": "node ../../scripts/clean.mjs", "prepack": "npm run build", "test": "node ../../scripts/test.mjs" }, diff --git a/packages/multipart/package.json b/packages/multipart/package.json index 65db1ef2..9341778d 100644 --- a/packages/multipart/package.json +++ b/packages/multipart/package.json @@ -15,7 +15,7 @@ }, "scripts": { "build": "tsc", - "prebuild": "rm -rf dist", + "prebuild": "node ../../scripts/clean.mjs", "prepack": "npm run build", "test": "node ../../scripts/test.mjs" }, diff --git a/scripts/clean.mjs b/scripts/clean.mjs new file mode 100644 index 00000000..df8a3baa --- /dev/null +++ b/scripts/clean.mjs @@ -0,0 +1,6 @@ +#!/usr/bin/env node +// Removes the `dist` directory of the current workspace (its `prebuild` script), +// without a shell command (`rm -rf` does not exist on Windows) +import { rmSync } from "node:fs"; + +rmSync("dist", { force: true, recursive: true }); diff --git a/scripts/consumer-check.mjs b/scripts/consumer-check.mjs index 9b274db1..8c78d1a2 100644 --- a/scripts/consumer-check.mjs +++ b/scripts/consumer-check.mjs @@ -17,6 +17,7 @@ import { } from "node:fs"; import { execFileSync } from "node:child_process"; import { join } from "node:path"; +import { npm } from "./npm.mjs"; import { tmpdir } from "node:os"; const root = join(import.meta.dirname, ".."); @@ -130,7 +131,9 @@ function writeExamples() { for (const workspace of workspaces) { const readme = readFileSync(join(root, workspace, "README.md"), "utf8"); const name = workspace.split("/").at(-1); - const blocks = readme.matchAll(/^```typescript\n(?[\s\S]*?)^```$/gmu); + const blocks = readme.matchAll( + /^```typescript\r?\n(?[\s\S]*?)^```\r?$/gmu, + ); for (const [index, { groups }] of [...blocks].entries()) { writeFileSync(join(examples, `${name}-${index + 1}.ts`), groups.code); count += 1; @@ -146,7 +149,12 @@ function writeExamples() { function run(command, args, cwd = dir) { process.stderr.write(`[consumer] ${command} ${args.join(" ")}\n`); - execFileSync(command, args, { cwd, stdio: ["ignore", "inherit", "inherit"] }); + const options = { cwd, stdio: ["ignore", "inherit", "inherit"] }; + if (command === "npm") { + npm(args, options); + } else { + execFileSync(command, args, options); + } } try { diff --git a/scripts/npm.mjs b/scripts/npm.mjs new file mode 100644 index 00000000..6324f84f --- /dev/null +++ b/scripts/npm.mjs @@ -0,0 +1,30 @@ +// Runs npm portably: Node.js runs the npm CLI that runs the current script +// (`npm_execpath`, which `npx` sets as well), or on Windows the one next to +// Node.js. There, `npm` is `npm.cmd`, which `execFile()` cannot run without a +// shell (https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows). +import { dirname, join } from "node:path"; +import { execFileSync } from "node:child_process"; + +function cli() { + const { npm_execpath: execpath } = process.env; + if (execpath) { + return execpath; + } + return process.platform === "win32" + ? join( + dirname(process.execPath), + "node_modules", + "npm", + "bin", + "npm-cli.js", + ) + : null; +} + +/** Same as `execFileSync("npm", args, options)` */ +export function npm(args, options = {}) { + const path = cli(); + return path === null + ? execFileSync("npm", args, options) + : execFileSync(process.execPath, [path, ...args], options); +} diff --git a/scripts/npm.test.mjs b/scripts/npm.test.mjs new file mode 100644 index 00000000..5782824e --- /dev/null +++ b/scripts/npm.test.mjs @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, it } from "node:test"; +import { deepEqual, equal, match } from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; + +const launcher = pathToFileURL(join(import.meta.dirname, "npm.mjs")).href; + +// Prints the output of `npm()` (scripts/npm.mjs) +function run(args, env) { + const script = `import { npm } from ${JSON.stringify(launcher)}; +process.stdout.write(npm(${JSON.stringify(args)}, { encoding: "utf8" }));`; + return spawnSync(process.execPath, ["--input-type=module", "-e", script], { + encoding: "utf8", + env, + }); +} + +describe("scripts/npm.mjs", () => { + let dir = ""; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "binden-npm-")); + }); + + afterEach(() => { + rmSync(dir, { force: true, recursive: true }); + }); + + it("runs the npm CLI of `npm_execpath` with Node.js", () => { + const cli = join(dir, "npm-cli.cjs"); + writeFileSync( + cli, + "process.stdout.write(JSON.stringify([process.execPath, ...process.argv.slice(2)]));", + ); + const { status, stdout } = run(["view", "binden"], { + ...process.env, + npm_execpath: cli, + }); + equal(status, 0); + deepEqual(JSON.parse(stdout), [process.execPath, "view", "binden"]); + }); + + it("runs npm without `npm_execpath`", () => { + const env = { ...process.env }; + delete env.npm_execpath; + const { status, stdout } = run(["--version"], env); + equal(status, 0); + match(stdout, /^\d+\.\d+\.\d+/u); + }); +}); diff --git a/scripts/release.mjs b/scripts/release.mjs index dc8321b1..26d76fcc 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -11,7 +11,7 @@ // `publish --skip-existing` works outside of CI too. // Everything is logged to stderr: @semantic-release/exec parses the stdout of `publishCmd`. import { readFileSync, writeFileSync } from "node:fs"; -import { execFileSync } from "node:child_process"; +import { npm as execNpm } from "./npm.mjs"; import { join } from "node:path"; const root = join(import.meta.dirname, ".."); @@ -31,7 +31,7 @@ function log(message) { } function npm(args, options = {}) { - return execFileSync("npm", args, { cwd: root, encoding: "utf8", ...options }); + return execNpm(args, { cwd: root, encoding: "utf8", ...options }); } function workspaces() { diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs index d3211f4f..132e7640 100644 --- a/scripts/release.test.mjs +++ b/scripts/release.test.mjs @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, it } from "node:test"; import { - chmodSync, copyFileSync, existsSync, mkdirSync, @@ -14,11 +13,11 @@ import { join } from "node:path"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; -// A fake `npm` that logs its arguments and emulates `npm view` (E404 unless the -// package version is listed in FAKE_NPM_PUBLISHED, or a network error with -// FAKE_NPM_OFFLINE). Every other command succeeds without doing anything. -const fakeNpm = `#!/usr/bin/env node -const { appendFileSync } = require("node:fs"); +// A fake npm CLI (run as `npm_execpath`, see scripts/npm.mjs) that logs its +// arguments and emulates `npm view` (E404 unless the package version is listed in +// FAKE_NPM_PUBLISHED, or a network error with FAKE_NPM_OFFLINE). Every other +// command succeeds without doing anything. +const fakeNpm = `const { appendFileSync } = require("node:fs"); const args = process.argv.slice(2); appendFileSync(process.env.FAKE_NPM_LOG, JSON.stringify(args) + "\\n"); if (args[0] === "view") { @@ -65,7 +64,7 @@ describe("scripts/release.mjs", () => { encoding: "utf8", env: { ...process.env, - PATH: `${join(root, "bin")}:${process.env.PATH ?? ""}`, + npm_execpath: join(root, "npm.cjs"), FAKE_NPM_LOG: join(root, "npm.log"), ...env, }, @@ -85,13 +84,13 @@ describe("scripts/release.mjs", () => { beforeEach(() => { root = mkdtempSync(join(tmpdir(), "binden-release-")); mkdirSync(join(root, "scripts")); - mkdirSync(join(root, "bin")); - copyFileSync( - join(import.meta.dirname, "release.mjs"), - join(root, "scripts/release.mjs"), - ); - writeFileSync(join(root, "bin/npm"), fakeNpm); - chmodSync(join(root, "bin/npm"), 0o755); + for (const script of ["release.mjs", "npm.mjs"]) { + copyFileSync( + join(import.meta.dirname, script), + join(root, "scripts", script), + ); + } + writeFileSync(join(root, "npm.cjs"), fakeNpm); writeJSON("package.json", { private: true, workspaces: Object.keys(manifests), diff --git a/scripts/test.test.mjs b/scripts/test.test.mjs index 761cc40a..edcae6d0 100644 --- a/scripts/test.test.mjs +++ b/scripts/test.test.mjs @@ -50,7 +50,12 @@ describe("scripts/test.mjs", () => { beforeEach(() => { fixture = mkdtempSync(join(tmpdir(), "binden-test-runner-")); - symlinkSync(join(root, "node_modules"), join(fixture, "node_modules")); + // A junction does not need the privilege of a symbolic link on Windows + symlinkSync( + join(root, "node_modules"), + join(fixture, "node_modules"), + "junction", + ); mkdirSync(join(fixture, "test")); writeFileSync( join(fixture, "test/setup.ts"), From 5edd33cb521ba51fa3b1ef7da1a70b5d4959b18f Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 02:06:46 +0300 Subject: [PATCH 068/675] ci: test on Windows A `windows-latest` job (Node.js 24) installs and builds the workspaces, then runs the tests and the tooling tests. The release waits for it as well. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e50f3fc..c77f50db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,10 +99,34 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} file: coverage/lcov.info + # The tests and the tooling (paths, line endings, child processes) on Windows + windows: + runs-on: windows-latest + + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: 24 + check-latest: true + cache: npm + + - name: Install dependencies and build the workspaces + run: npm ci + + - name: Tests + run: npm test + + - name: Tooling tests (release script, test runner) + run: npm run test:tooling + release: if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: [lint, test] + needs: [lint, test, windows] runs-on: ubuntu-latest From e5add1d08da4b8a49e1cd0f660d94a7cbd007945 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 02:06:56 +0300 Subject: [PATCH 069/675] ci: deploy the docs without waiting for the release The `pages` job waits for the checks instead of the release: a failed release no longer skips the deployment of the docs of `main`. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c77f50db..0ec6fa0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,8 +170,9 @@ jobs: pages: if: github.event_name == 'push' && github.ref == 'refs/heads/main' - # The documentation artifact is uploaded by the `lint` job. - needs: [release] + # The documentation artifact is uploaded by the `lint` job. The docs of `main` are + # deployed even when no release is due (or the release fails). + needs: [lint, test, windows] runs-on: ubuntu-latest From 6918a19d2a193ded47b2f7e9a7c87e2811898e92 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 02:10:05 +0300 Subject: [PATCH 070/675] ci: type check the consumer and the README examples with TypeScript 7 `npm run consumer:check` runs its checks with TypeScript 7 (the native compiler) and `@types/node` 24 as well. The repository itself stays on TypeScript 6: typescript-eslint and TypeDoc support TypeScript up to 6.0. The README explains the dependencies that are not at their latest version (TypeScript 7, `@types/node` 26, the version 10 of the changelog preset) and the advisories of the development dependencies. Co-Authored-By: Claude Opus 5.5 --- README.md | 23 ++++++++++++++++++++++- scripts/consumer-check.mjs | 31 +++++++++++++++++++++---------- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2d17337a..dc3c2f3c 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,26 @@ Packages consume each other through the compiled `dist/` (`@binden/body`, `@bind `@binden/multipart` import `binden`, which imports `@binden/logger`): run `npm run build` after changing a package that others depend on. +## Dependencies + +Dependencies are kept at their latest versions, except: + +- **TypeScript 7.** The repository is built with TypeScript 6: typescript-eslint and TypeDoc support + TypeScript up to 6.0 and need the JavaScript API of the compiler, which the native TypeScript 7 + does not provide yet. The packages work with TypeScript 7: `npm run consumer:check` type checks + the packed packages and the README examples with it too. +- **`@types/node` 26.** The type definitions follow the oldest supported Node.js (24), so that an API + added later cannot be used by accident (the tests run on Node.js 24 and 26). +- **`conventional-changelog-conventionalcommits` 10.** semantic-release 25 generates the release + notes with `conventional-changelog-writer` 8, and the version 10 of the preset requires the + version 9 of the writer (the release fails). + +The published packages have no known vulnerabilities (`npm audit --omit=dev`). The advisories of the +development dependencies come from the release tooling: `braces` (through `micromatch`, used by +semantic-release) has no fixed version, and the other ones are bundled with npm 11.21.0, the latest +version that `@semantic-release/npm` supports (bundled dependencies cannot be updated separately). +They do not affect the published packages. + ## Known issues - With `@types/node` 26 and `skipLibCheck: false`, TypeScript reports @@ -42,7 +62,8 @@ changing a package that others depend on. [`thread-stream`](https://github.com/pinojs/thread-stream), which `pino` (used by `@binden/logger`) depends on. Enable `skipLibCheck` until `thread-stream` is updated ([pinojs/thread-stream#228](https://github.com/pinojs/thread-stream/issues/228)). - `npm run consumer:check` type checks the packed packages with `@types/node` 24 (strict) and 26. + `npm run consumer:check` type checks the packed packages with `@types/node` 24 (strict) and 26 + (with `skipLibCheck`). ## Releases diff --git a/scripts/consumer-check.mjs b/scripts/consumer-check.mjs index 8c78d1a2..6bc43006 100644 --- a/scripts/consumer-check.mjs +++ b/scripts/consumer-check.mjs @@ -1,12 +1,15 @@ #!/usr/bin/env node // Checks the packed workspaces the way a consumer uses them: the tarballs are -// installed into a fresh project, which is type checked (with `@types/node` 24 -// and 26) and then run as ESM against a real server. The TypeScript examples of -// the READMEs are type checked against the same installation. +// installed into a fresh project, which is type checked and then run as ESM +// against a real server. The TypeScript examples of the READMEs are type checked +// against the same installation. Each check runs with: // -// `@types/node` 26 is checked with `skipLibCheck`: the types of `thread-stream` -// (pulled in by `pino`, used by `@binden/logger`) still refer to -// `worker_threads.TransferListItem`, which `@types/node` 26 removed. +// - the TypeScript of this repository and `@types/node` 24; +// - the same TypeScript and `@types/node` 26, with `skipLibCheck`: the types of +// `thread-stream` (pulled in by `pino`, used by `@binden/logger`) still refer to +// `worker_threads.TransferListItem`, which `@types/node` 26 removed; +// - TypeScript 7 (the native compiler, which the tooling of this repository does +// not support yet) and `@types/node` 24. import { mkdirSync, mkdtempSync, @@ -174,10 +177,18 @@ try { writeFileSync(join(dir, "consumer.ts"), consumer); const examples = writeExamples(); - for (const [types, skipLibCheck] of [ - ["24", false], - ["26", true], + for (const { typescript, types, skipLibCheck } of [ + { + typescript: devDependencies.typescript, + types: "24", + skipLibCheck: false, + }, + { typescript: devDependencies.typescript, types: "26", skipLibCheck: true }, + { typescript: "7", types: "24", skipLibCheck: false }, ]) { + process.stderr.write( + `[consumer] typescript@${typescript}, @types/node@${types}\n`, + ); run("npm", [ "install", // Every package gets its own node_modules: an import of a dependency that @@ -187,7 +198,7 @@ try { "--no-fund", "--loglevel=warn", ...tarballs, - `typescript@${devDependencies.typescript}`, + `typescript@${typescript}`, `@types/node@${types}`, ...Object.entries(exampleDependencies).map( ([name, version]) => `${name}@${version}`, From fefde3916d6f488797a813000fb531906a435e6c Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 02:12:25 +0300 Subject: [PATCH 071/675] test: expect the errors of a client that went away on every platform - A write that is pending when the socket is closed (after the `ECONNRESET` of a read) fails with `ECANCELED` (seen on the Linux runners of GitHub Actions); Windows reports `ECONNABORTED`. - The test of a single chunk that is not flushed before the client goes away is skipped on Windows: Windows takes a whole write while its send buffer has room, so the chunk is flushed to the connection at once and `send()` resolves, as it should. Co-Authored-By: Claude Opus 5.5 --- packages/binden/test/response.test.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/binden/test/response.test.ts b/packages/binden/test/response.test.ts index 457d11ad..97d382b2 100644 --- a/packages/binden/test/response.test.ts +++ b/packages/binden/test/response.test.ts @@ -44,8 +44,18 @@ function abortAfterFirstChunk(): Promise { }); } -/** The error codes of a `send()` to a client that went away */ -const gone = new Set(["EPIPE", "ECONNRESET", "ERR_STREAM_PREMATURE_CLOSE"]); +/** + * The error codes of a `send()` to a client that went away: a write fails + * (`ECANCELED` when the socket is closed while it is pending, `ECONNABORTED` on + * Windows), or the response is closed before it finishes + */ +const gone = new Set([ + "ECANCELED", + "ECONNABORTED", + "ECONNRESET", + "EPIPE", + "ERR_STREAM_PREMATURE_CLOSE", +]); /** The error code of a settled `send()` (`null` when it has resolved) */ function settle(promise: Promise): Promise { @@ -373,7 +383,12 @@ suite("BindenResponse", () => { test( ".send() (rejects when the client goes away before the flush)", - { timeout: 10_000 }, + { + // Windows takes a whole write while its send buffer has room: the chunk + // is flushed to the connection at once + skip: process.platform === "win32", + timeout: 10_000, + }, async () => { // One chunk with the whole `Content-Length`: read at once, but not sent const size = 64 * 1024 * 1024; From 321007e0369f134148c5c875ea1fda3b8ef750e5 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 03:02:37 +0300 Subject: [PATCH 072/675] fix: never crash on the errors of a discarded source in binden A `Readable` that a response does not send (to `HEAD`, or with a 1xx, 204, 205 or 304 status) was destroyed without an `error` listener: a source whose `destroy()` fails (e.g. a file that cannot be closed) crashed the process with an unhandled `error` event. `send(Readable)` on an ended response did not destroy the source at all, which kept its file open. Such a source is now destroyed with its errors ignored, also on an ended response, and a sent source keeps its `error` listener after the first error. The new process safety tests run their fixtures in a process of their own, so that a crash fails the test instead of the test runner. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/response.ts | 18 +++++- .../binden/test/fixtures/discarded-source.ts | 61 +++++++++++++++++++ packages/binden/test/process.test.ts | 28 +++++++++ packages/binden/test/response.test.ts | 18 +++++- 4 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 packages/binden/test/fixtures/discarded-source.ts create mode 100644 packages/binden/test/process.test.ts diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index 6c002144..bcba90e6 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -76,6 +76,10 @@ export class BindenResponse< encoding?: BufferEncoding, ): Promise { if (this.writableEnded) { + if (data instanceof Readable) { + // Nothing of it will be sent: release it (e.g. its file) + BindenResponse.#discard(data); + } return Promise.resolve(); } @@ -98,7 +102,7 @@ export class BindenResponse< if (data instanceof Readable) { if (this.#bodiless()) { // Nothing of the source would be sent: it is not read at all - data.destroy(); + BindenResponse.#discard(data); const done = this.#finished(() => false); this.end(); return done; @@ -233,7 +237,8 @@ export class BindenResponse< .once("end", () => { this.end(); }) - .once("error", (error) => { + // Every error (also one emitted while the source is destroyed later) + .on("error", (error) => { // Nothing sent yet: the error can still be replied (see `.started`) if (!written) { this.#started = false; @@ -247,6 +252,15 @@ export class BindenResponse< }); } + /** Destroys a source that will not be sent, whose errors are no longer of interest */ + static #discard(source: Readable): void { + source + .on("error", () => { + // E.g. its file failed to close: nobody waits for it + }) + .destroy(); + } + /** * Resolves when the response has finished (flushed to the connection), rejects * when it is closed before (e.g. the client went away), unless it is diff --git a/packages/binden/test/fixtures/discarded-source.ts b/packages/binden/test/fixtures/discarded-source.ts new file mode 100644 index 00000000..20493d90 --- /dev/null +++ b/packages/binden/test/fixtures/discarded-source.ts @@ -0,0 +1,61 @@ +// Run by `process.test.ts`: the sources that a response does not send are +// destroyed, and their errors must not crash the process +import { type IncomingMessage, request } from "node:http"; +import type { AddressInfo } from "node:net"; +import { Readable } from "node:stream"; +import { once } from "node:events"; +import { Binden } from "../../index.js"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +/** A source whose `destroy()` fails */ +function failing(): Readable { + return new Readable({ + read(): void { + // Nothing to read + }, + destroy(_error, callback): void { + callback(new Error("The source failed to close")); + }, + }); +} + +const app = new Binden().use(async (context) => { + const { pathname } = context.url; + if (pathname === "/no-content") { + context.status(204); + } else if (pathname === "/ended") { + context.response.end(); + } + await context.send(failing()); +}); + +const server = app.createServer().listen(0); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const statuses: number[] = []; +for (const [method, path] of [ + ["HEAD", "/"], + ["GET", "/no-content"], + ["GET", "/ended"], +]) { + const response = await new Promise((resolve, reject) => { + request({ agent: false, method, path, port }, resolve) + .on("error", reject) + .end(); + }); + response.resume(); + await once(response, "end"); + statuses.push(response.statusCode ?? 0); +} + +// The errors of the destroyed sources are emitted asynchronously +await new Promise((resolve) => { + setImmediate(resolve); +}); +server.close(); +process.stdout.write(`${statuses.join(" ")}\n`); diff --git a/packages/binden/test/process.test.ts b/packages/binden/test/process.test.ts new file mode 100644 index 00000000..bb83aa89 --- /dev/null +++ b/packages/binden/test/process.test.ts @@ -0,0 +1,28 @@ +import { deepStrictEqual } from "node:assert"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { suite, test } from "node:test"; + +/** + * Runs a fixture (`test/fixtures`) in a process of its own: a crash (e.g. an + * unhandled `error` event) is its exit code and output, not a failure of the + * test runner + */ +function run(fixture: string): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + ["--import", "tsx", join(import.meta.dirname, "fixtures", fixture)], + { encoding: "utf8", timeout: 30_000 }, + ); + return { status, stdout, stderr }; +} + +suite("Process safety", () => { + test("a discarded source that fails to close does not crash the process", () => { + deepStrictEqual(run("discarded-source.ts"), { + status: 0, + stdout: "200 204 200\n", + stderr: "", + }); + }); +}); diff --git a/packages/binden/test/response.test.ts b/packages/binden/test/response.test.ts index 97d382b2..888f7aaf 100644 --- a/packages/binden/test/response.test.ts +++ b/packages/binden/test/response.test.ts @@ -9,7 +9,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import fastJSON from "fast-json-stringify"; import fetch from "node-fetch"; -import type { Stats } from "node:fs"; +import { createReadStream, type Stats } from "node:fs"; import { Readable } from "node:stream"; import { afterEach, beforeEach, suite, test } from "node:test"; @@ -218,6 +218,22 @@ suite("BindenResponse", () => { ok(response.ok); }); + test(".send() (`writableEnded === true` releases a `Readable`)", async () => { + const source = createReadStream(filePath); + const settled = new Promise((resolve) => { + server.once("request", (_request, response) => { + response.end(() => { + resolve(settle(response.send(source))); + }); + }); + }); + await (await fetch(url)).text(); + + deepStrictEqual(await settled, null); + // Its file is closed + ok(source.destroyed); + }); + test(".send() (with encoding)", async () => { const cookie1 = new Cookie({ key: "__Secure-K1", value: "V1" }); const cookie2 = new Cookie({ key: "__Host-K2", value: "V2" }); From 61e7ae12d8278b812da9860dd44f641e6d986183 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 03:05:07 +0300 Subject: [PATCH 073/675] fix: settle send() of a buffer on the bytes flushed to the connection in binden `send()` passed a string or a buffer to `end()` and resolved on `finish`. Node.js 24 emits `finish` once the response is ended even when the write has failed: a client that went away after 64 KiB of a 64 MiB buffer left `send()` resolved on Node.js 24, while Node.js 26 rejects. As for a `Readable`, the body is now written with a callback (and the response ended after it), so that a failed write rejects `send()` with its error. Its `Content-Length` is set, so that it is not sent chunked. A destroyed response still rejects with `ERR_STREAM_PREMATURE_CLOSE`. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/response.ts | 28 +++++++++++++++++++++------ packages/binden/test/response.test.ts | 22 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index bcba90e6..4c9e66cd 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -114,7 +114,7 @@ export class BindenResponse< // before (e.g. the client went away) const done = this.#finished(() => false); - if (typeof data === "undefined" || data === "") { + if (typeof data === "undefined" || data === "" || this.destroyed) { this.end(); return done; } @@ -141,13 +141,29 @@ export class BindenResponse< this.setHeader("Content-Length", Buffer.byteLength(body, encoding)); } this.end(); - } else if (typeof body === "string" && encoding) { - this.end(body, encoding); - } else { - this.end(body); + return done; } - return done; + // The body is written rather than passed to `end()`: a write that fails + // (e.g. the client went away) rejects, although Node.js 24 emits `finish` + // once the response is ended. Its length keeps it from being chunked. + if (!this.headersSent) { + this.setHeader("Content-Length", Buffer.byteLength(body, encoding)); + } + return new Promise((resolve, reject) => { + const written = (error?: Error | null): void => { + if (error) { + reject(error); + } + }; + if (typeof body === "string") { + this.write(body, encoding ?? "utf8", written); + } else { + this.write(body, written); + } + this.end(); + done.then(resolve, reject); + }); } /** Send response as `application/json` */ diff --git a/packages/binden/test/response.test.ts b/packages/binden/test/response.test.ts index 888f7aaf..a9bb6a95 100644 --- a/packages/binden/test/response.test.ts +++ b/packages/binden/test/response.test.ts @@ -424,6 +424,28 @@ suite("BindenResponse", () => { }, ); + test( + ".send() (rejects a `Buffer` when the client goes away before the flush)", + { + // Windows takes a whole write while its send buffer has room + skip: process.platform === "win32", + timeout: 10_000, + }, + async () => { + const size = 64 * 1024 * 1024; + const settled = new Promise((resolve) => { + server.once("request", (_request, response) => { + resolve(settle(response.send(Buffer.alloc(size)))); + }); + }); + await abortAfterFirstChunk(); + + // Node.js 24 emits `finish` although the write has failed + const code = await settled; + ok(gone.has(code as string), String(code)); + }, + ); + test( ".send() (pauses a `Readable` while the client does not read)", { timeout: 10_000 }, From 8bad26cdd4d9022bbf601b5d43ef5f6d178be7d7 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 03:09:54 +0300 Subject: [PATCH 074/675] fix: frame every response by its final status in binden `send()` kept the framing headers that were set before it: a `Content-Length: 99` stayed on a 204, on an empty response or on the 416 of `sendFile()` (the client waited for 99 bytes), a 205 with a body was sent with the length of a body it did not send, and a stale `Transfer-Encoding` was kept. On a reused connection, such a response makes the client read the next response as its body. The framing now follows the final status (RFC 9110, sections 8.6 and 15): 1xx, 204 and 304 have no `Content-Length` nor `Transfer-Encoding`, 205 has `Content-Length: 0`, and any other response gets the length of its body (0 without one), also for `HEAD`, which keeps a length it was given when it gets no body. A response without a body no longer gets a default `Content-Type`. The new keep-alive tests send every request on one connection. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/response.ts | 70 +++++++++----- packages/binden/test/keep-alive.test.ts | 118 ++++++++++++++++++++++++ packages/binden/test/response.test.ts | 114 +++++++++++++++++++++++ 3 files changed, 281 insertions(+), 21 deletions(-) create mode 100644 packages/binden/test/keep-alive.test.ts diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index 4c9e66cd..face8bb7 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -103,6 +103,7 @@ export class BindenResponse< if (this.#bodiless()) { // Nothing of the source would be sent: it is not read at all BindenResponse.#discard(data); + this.#frame(null); const done = this.#finished(() => false); this.end(); return done; @@ -115,31 +116,30 @@ export class BindenResponse< const done = this.#finished(() => false); if (typeof data === "undefined" || data === "" || this.destroyed) { + // Without a body, `HEAD` keeps the length it may have been given + this.#frame(this.req.method === "HEAD" ? null : 0); this.end(); return done; } + const body = + data instanceof Buffer || typeof data === "string" + ? data + : data.toString(); + if ( !this.headersSent && - typeof this.getHeader("Content-Type") === "undefined" + typeof this.getHeader("Content-Type") === "undefined" && + (this.req.method === "HEAD" || + !BindenResponse.#noContent(this.statusCode)) ) { this.setHeader("Content-Type", ct_text); } - const body = - data instanceof Buffer || typeof data === "string" - ? data - : data.toString(); + this.#frame(Buffer.byteLength(body, encoding)); if (this.#bodiless()) { // The headers of the body (as for `GET`), but not the body itself - if ( - this.req.method === "HEAD" && - !this.headersSent && - !this.hasHeader("Content-Length") - ) { - this.setHeader("Content-Length", Buffer.byteLength(body, encoding)); - } this.end(); return done; } @@ -147,9 +147,6 @@ export class BindenResponse< // The body is written rather than passed to `end()`: a write that fails // (e.g. the client went away) rejects, although Node.js 24 emits `finish` // once the response is ended. Its length keeps it from being chunked. - if (!this.headersSent) { - this.setHeader("Content-Length", Buffer.byteLength(body, encoding)); - } return new Promise((resolve, reject) => { const written = (error?: Error | null): void => { if (error) { @@ -194,16 +191,47 @@ export class BindenResponse< /** Send a file */ /** A response without a body: to `HEAD`, or with a 1xx, 204, 205 or 304 status */ #bodiless(): boolean { - const { statusCode } = this; return ( - this.req.method === "HEAD" || - (statusCode >= 100 && statusCode < 200) || - statusCode === 204 || - statusCode === 205 || - statusCode === 304 + this.req.method === "HEAD" || BindenResponse.#noContent(this.statusCode) + ); + } + + /** A status whose responses never have a body */ + static #noContent(status: number): boolean { + return ( + (status >= 100 && status < 200) || + status === 204 || + status === 205 || + status === 304 ); } + /** + * Frames the response by its final status (RFC 9110, sections 8.6 and 15), + * with the `length` of its body (`null` when it is not known): a stale + * `Content-Length` or `Transfer-Encoding` (e.g. of a body that an error + * replaced) would make the client wait for bytes that never come, or read + * the next response of the connection as this body. + */ + #frame(length: number | null): void { + const { statusCode } = this; + if (this.headersSent) { + return; + } else if (statusCode === 205) { + // No content, framed as such (RFC 9110, section 15.3.6) + this.removeHeader("Transfer-Encoding"); + this.setHeader("Content-Length", 0); + } else if (BindenResponse.#noContent(statusCode)) { + // Never a body, nor the length of one + this.removeHeader("Content-Length"); + this.removeHeader("Transfer-Encoding"); + } else if (length !== null) { + // Also for `HEAD`: the length of the body it would send + this.removeHeader("Transfer-Encoding"); + this.setHeader("Content-Length", length); + } + } + /** * Writes a `Readable` into the response (with backpressure) and settles like * `#finished()`. The client has the whole response (and may close the diff --git a/packages/binden/test/keep-alive.test.ts b/packages/binden/test/keep-alive.test.ts new file mode 100644 index 00000000..65be5015 --- /dev/null +++ b/packages/binden/test/keep-alive.test.ts @@ -0,0 +1,118 @@ +/* eslint-disable init-declarations */ +import { deepStrictEqual } from "node:assert"; +import { once } from "node:events"; +import { Agent, type Server, request } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, suite, test } from "node:test"; + +import { Binden, type Context } from "../index.js"; + +/** The status, the body and whether the connection was reused */ +interface IExchange { + status: number; + body: string; + reused: boolean; +} + +// The other tests use a fresh connection for every request (`test/setup.ts`): +// here every request reuses the one connection of the agent. A response whose +// framing does not match its body leaves the next one waiting for bytes that +// never come, or reading the rest of the previous response. +suite("Keep-alive", () => { + let server: Server; + let agent: Agent; + + const routes: Partial Promise>> = { + "/ok": (context) => context.send("ok"), + "/no-content": (context) => + context.setHeader("Content-Length", 99).status(204).send(), + "/reset-content": (context) => + context.setHeader("Content-Length", 11).status(205).send("hello world"), + "/unsatisfiable": (context) => + context + .setHeader("Content-Length", 99) + .response.sendFile(import.meta.filename), + "/head": (context) => context.send("abc"), + }; + + const exchange = ( + path: string, + method = "GET", + headers: Record = {}, + ): Promise => + new Promise((resolve, reject) => { + const { port } = server.address() as AddressInfo; + const sent = request( + { + agent, + headers, + method, + path, + port, + signal: AbortSignal.timeout(5000), + }, + (response) => { + let body = ""; + response + .setEncoding("utf8") + .on("data", (chunk: string) => { + body += chunk; + }) + .once("end", () => { + const status = response.statusCode ?? 0; + resolve({ status, body, reused: sent.reusedSocket }); + }); + }, + ); + sent.on("error", reject).end(); + }); + + beforeEach(async () => { + agent = new Agent({ keepAlive: true, maxSockets: 1 }); + const app = new Binden().use(async (context) => { + await routes[context.url.pathname]?.(context); + }); + server = app.createServer().listen(0); + await once(server, "listening"); + // The connection is open: the next request reuses it + deepStrictEqual(await exchange("/ok"), { + status: 200, + body: "ok", + reused: false, + }); + }); + + afterEach(() => { + agent.destroy(); + server.closeAllConnections(); + server.close(); + }); + + const cases: [string, string, string, Record, number][] = [ + ["204 with a stale `Content-Length`", "/no-content", "GET", {}, 204], + ["205 with a body", "/reset-content", "GET", {}, 205], + [ + "416 with a stale `Content-Length`", + "/unsatisfiable", + "GET", + { Range: "bytes=99999999-" }, + 416, + ], + ["HEAD with a body", "/head", "HEAD", {}, 200], + ]; + + for (const [name, path, method, headers, status] of cases) { + test(`${name}, then another request`, async () => { + deepStrictEqual(await exchange(path, method, headers), { + status, + body: "", + reused: true, + }); + deepStrictEqual(await exchange("/ok"), { + status: 200, + body: "ok", + reused: true, + }); + }); + } +}); diff --git a/packages/binden/test/response.test.ts b/packages/binden/test/response.test.ts index a9bb6a95..52cbdb14 100644 --- a/packages/binden/test/response.test.ts +++ b/packages/binden/test/response.test.ts @@ -65,6 +65,47 @@ function settle(promise: Promise): Promise { ); } +/** The status line, the headers and the body of a raw response */ +interface IRawResponse { + status: string; + headers: Record; + body: string; +} + +/** + * Sends a raw request to `url` on a connection that the server closes after its + * response, and reads the response as it is on the wire + */ +async function exchange(method = "GET", headers = ""): Promise { + const { hostname, port } = new URL(url); + const socket = new Socket(); + socket.setEncoding("latin1"); + let data = ""; + socket.on("data", (chunk: string) => { + data += chunk; + }); + socket.connect(Number(port), hostname); + socket.write( + `${method} / HTTP/1.1\r\nHost: ${hostname}\r\nConnection: close\r\n${headers}\r\n`, + ); + await once(socket, "close"); + const [head = "", ...rest] = data.split("\r\n\r\n"); + const [status = "", ...lines] = head.split("\r\n"); + return { + status, + headers: Object.fromEntries( + lines.map((line) => { + const index = line.indexOf(":"); + return [ + line.slice(0, index).toLowerCase(), + line.slice(index + 1).trim(), + ]; + }), + ), + body: rest.join("\r\n\r\n"), + }; +} + suite("BindenResponse", () => { // A new directory for every test let root: string; @@ -558,6 +599,76 @@ suite("BindenResponse", () => { } }); + test(".send() (frames the response by its final status)", async () => { + const framing = ["content-length", "content-type", "transfer-encoding"]; + const check = async ( + method: string, + reply: (response: BindenResponse) => Promise, + expected: Record, + body = "", + ): Promise => { + const replied = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + reply(response).then(resolve, reject); + }); + }); + const response = await exchange(method); + await replied; + const { headers } = response; + const sent = framing + .filter((name) => name in headers) + .map((name) => [name, headers[name]]); + deepStrictEqual( + { ...Object.fromEntries(sent), body: response.body }, + { ...expected, body }, + `${method} ${response.status}`, + ); + }; + const text = { "content-type": ct_text }; + const stale = { "Content-Length": 99, "Transfer-Encoding": "chunked" }; + + // Never a body, nor the length of one + await check("GET", (r) => r.set(stale).status(204).send(), {}); + await check("GET", (r) => r.status(204).send("data"), {}); + await check("GET", (r) => r.set(stale).status(304).send(), {}); + // No content, framed as such + await check("GET", (r) => r.set(stale).status(205).send("hello world"), { + "content-length": "0", + }); + // The length of the body, whatever was set before + await check( + "GET", + (r) => r.set(stale).send("abc"), + { ...text, "content-length": "3" }, + "abc", + ); + await check( + "GET", + (r) => r.set(stale).status(404).send(Buffer.from("x")), + { ...text, "content-length": "1" }, + "x", + ); + await check("GET", (r) => r.set(stale).send(), { "content-length": "0" }); + // `HEAD`: the length of the body it would send, or the one it was given + await check("HEAD", (r) => r.send("abc"), { + ...text, + "content-length": "3", + }); + await check("HEAD", (r) => r.setHeader("Content-Length", 1234).send(), { + "content-length": "1234", + }); + // Sent headers are not changed: the body follows their framing + await check( + "GET", + async (r) => { + r.flushHeaders(); + await r.send("abc"); + }, + { "transfer-encoding": "chunked" }, + "3\r\nabc\r\n0\r\n\r\n", + ); + }); + test(".send() (a `Readable` is not read without a body)", async () => { const check = async (method: string, status: number): Promise => { let reads = 0; @@ -840,7 +951,9 @@ suite("BindenResponse", () => { test(".sendFile() (416 response with invalid `Range`)", async () => { const serverPromise = new Promise((resolve, reject) => { server.once("request", (_request, response) => { + // A stale length would leave the client waiting for its bytes response + .setHeader("Content-Length", 99) .sendFile(filePath, file_stats) .catch(reject) .finally(() => response.end(resolve)); @@ -852,6 +965,7 @@ suite("BindenResponse", () => { deepStrictEqual(response.status, 416); const cr = response.headers.get("Content-Range"); deepStrictEqual(cr, `bytes */${msg.byteLength}`); + deepStrictEqual(response.headers.get("Content-Length"), "0"); await serverPromise; const data = await response.text(); From 40fefd780d9d5a37f6502acc6f638314e79f1cd8 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 03:18:03 +0300 Subject: [PATCH 075/675] fix: settle send(Readable) when its source closes early or misses its length in binden A source destroyed without an error before its end (e.g. `read() { this.destroy() }`) left the response open and `send()` pending until the client gave up, with a chunked body that was never terminated. A source with fewer bytes than its `Content-Length` left the client waiting for the rest, and one with more bytes sent them on the connection, where a client reads them as the next response. Such a source now rejects `send()`: `ERR_STREAM_PREMATURE_CLOSE` when it closes before its end, `ERR_HTTP_CONTENT_LENGTH_MISMATCH` when it does not have its `Content-Length` (the bytes beyond it are not sent). When nothing was sent yet, the error can still be replied; otherwise the response is destroyed, so that the client knows that the body is truncated. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/logger.ts | 1 + packages/binden/src/response.ts | 85 +++++++++++++++++------ packages/binden/test/keep-alive.test.ts | 18 ++++- packages/binden/test/response.test.ts | 89 +++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 22 deletions(-) diff --git a/packages/binden/src/logger.ts b/packages/binden/src/logger.ts index dee89e78..4f68ac52 100644 --- a/packages/binden/src/logger.ts +++ b/packages/binden/src/logger.ts @@ -26,6 +26,7 @@ const errorTypes: ReadonlyMap = new Map([ /** The error codes the default logger keeps: system errors and some stream/HTTP ones */ const errorCodes: ReadonlySet = new Set([ ...Object.keys(constants.errno), + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", "ERR_HTTP_HEADERS_SENT", "ERR_HTTP_INVALID_HEADER_VALUE", "ERR_INVALID_ARG_TYPE", diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index face8bb7..02c75e80 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -236,17 +236,35 @@ export class BindenResponse< * Writes a `Readable` into the response (with backpressure) and settles like * `#finished()`. The client has the whole response (and may close the * connection) once the `Content-Length` bytes are flushed to the connection, - * maybe before the source ends. The header is read at the - * close: compression removes it. + * maybe before the source ends. The header is read when it is needed: + * compression removes it. + * + * A source that fails, closes before its end, or does not have exactly its + * `Content-Length` rejects. Nothing sent yet, the error can still be replied + * (see `.started`); otherwise the response is destroyed, so that a truncated + * body does not look complete, and more bytes are not read by the client as + * the next response of the connection. */ #pipe(data: Readable): Promise { let flushed = 0; - let written = false; - const done = this.#finished( - () => flushed >= Number(this.getHeader("Content-Length")), - ); + let sent = 0; + const done = this.#finished(() => { + const length = this.#length(); + return length !== null && flushed >= length; + }); return new Promise((resolve, reject) => { + // Once the response is closed, `#finished()` tells how it ended + let closed = false; + const fail = (error: Error): void => { + if (sent === 0) { + this.#started = false; + } else { + this.destroy(); + } + reject(error); + }; this.once("close", () => { + closed = true; // Releases the source (e.g. the file of `sendFile()`) if (!data.readableEnded) { data.destroy(); @@ -256,7 +274,12 @@ export class BindenResponse< .on("data", (chunk: Uint8Array | string) => { try { const size = Buffer.byteLength(chunk); - written = true; + const length = this.#length(); + if (length !== null && sent + size > length) { + data.destroy(BindenResponse.#mismatch(sent + size, length)); + return; + } + sent += size; const accepted = this.write(chunk, (error?: Error | null) => { if (error) { // Not delivered: the client went away (Node.js 24 may still @@ -279,15 +302,20 @@ export class BindenResponse< } }) .once("end", () => { - this.end(); + const length = this.#length(); + if (length !== null && sent < length) { + fail(BindenResponse.#mismatch(sent, length)); + } else { + this.end(); + } }) // Every error (also one emitted while the source is destroyed later) - .on("error", (error) => { - // Nothing sent yet: the error can still be replied (see `.started`) - if (!written) { - this.#started = false; + .on("error", fail) + .once("close", () => { + // Destroyed without an error before its end + if (!closed && !data.readableEnded && data.errored === null) { + fail(BindenResponse.#premature()); } - reject(error); }); done.then(resolve, (error: Error) => { data.destroy(); @@ -296,6 +324,27 @@ export class BindenResponse< }); } + /** The `Content-Length` of the response, `null` without a valid one */ + #length(): number | null { + const length = Number(this.getHeader("Content-Length") ?? Number.NaN); + return Number.isSafeInteger(length) && length >= 0 ? length : null; + } + + /** The error of a close before the end */ + static #premature(): Error { + return Object.assign(new Error("Premature close"), { + code: "ERR_STREAM_PREMATURE_CLOSE", + }); + } + + /** The error of a body that does not have its `Content-Length` */ + static #mismatch(size: number, length: number): Error { + const message = `The body (${size} bytes) does not match its Content-Length (${length} bytes)`; + return Object.assign(new Error(message), { + code: "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + }); + } + /** Destroys a source that will not be sent, whose errors are no longer of interest */ static #discard(source: Readable): void { source @@ -312,12 +361,8 @@ export class BindenResponse< */ #finished(complete: () => boolean): Promise { return new Promise((resolve, reject) => { - const premature = (): Error => - Object.assign(new Error("Premature close"), { - code: "ERR_STREAM_PREMATURE_CLOSE", - }); if (this.destroyed) { - reject(premature()); + reject(BindenResponse.#premature()); return; } const listeners: [string, (error?: Error) => void][] = []; @@ -341,13 +386,13 @@ export class BindenResponse< [ "close", (): void => { - settle(complete() ? null : premature()); + settle(complete() ? null : BindenResponse.#premature()); }, ], [ "error", (error?: Error): void => { - settle(error ?? premature()); + settle(error ?? BindenResponse.#premature()); }, ], ); diff --git a/packages/binden/test/keep-alive.test.ts b/packages/binden/test/keep-alive.test.ts index 65be5015..8a40812b 100644 --- a/packages/binden/test/keep-alive.test.ts +++ b/packages/binden/test/keep-alive.test.ts @@ -1,8 +1,9 @@ /* eslint-disable init-declarations */ -import { deepStrictEqual } from "node:assert"; +import { deepStrictEqual, rejects } from "node:assert"; import { once } from "node:events"; import { Agent, type Server, request } from "node:http"; import type { AddressInfo } from "node:net"; +import { Readable } from "node:stream"; import { afterEach, beforeEach, suite, test } from "node:test"; import { Binden, type Context } from "../index.js"; @@ -33,6 +34,8 @@ suite("Keep-alive", () => { .setHeader("Content-Length", 99) .response.sendFile(import.meta.filename), "/head": (context) => context.send("abc"), + "/short": (context) => + context.setHeader("Content-Length", 10).send(Readable.from(["abc"])), }; const exchange = ( @@ -61,7 +64,8 @@ suite("Keep-alive", () => { .once("end", () => { const status = response.statusCode ?? 0; resolve({ status, body, reused: sent.reusedSocket }); - }); + }) + .once("error", reject); }, ); sent.on("error", reject).end(); @@ -101,6 +105,16 @@ suite("Keep-alive", () => { ["HEAD with a body", "/head", "HEAD", {}, 200], ]; + test("a body shorter than its `Content-Length` closes the connection", async () => { + // The client knows that the body is truncated + await rejects(exchange("/short"), { code: "ECONNRESET" }); + deepStrictEqual(await exchange("/ok"), { + status: 200, + body: "ok", + reused: false, + }); + }); + for (const [name, path, method, headers, status] of cases) { test(`${name}, then another request`, async () => { deepStrictEqual(await exchange(path, method, headers), { diff --git a/packages/binden/test/response.test.ts b/packages/binden/test/response.test.ts index 52cbdb14..1d10b56e 100644 --- a/packages/binden/test/response.test.ts +++ b/packages/binden/test/response.test.ts @@ -570,6 +570,95 @@ suite("BindenResponse", () => { deepStrictEqual(await settle(sent), "ERR_STREAM_PREMATURE_CLOSE"); }); + test(".send() (a `Readable` that fails before sending can be replied)", async () => { + const check = async ( + source: Readable, + length: number | null, + error: string, + ): Promise => { + const replied = new Promise((resolve) => { + server.once("request", (_request, response) => { + if (length !== null) { + response.setHeader("Content-Length", length); + } + resolve( + settle(response.send(source)).then(async (code) => { + const { started } = response; + await response.status(500).send("replied"); + return { code, started }; + }), + ); + }); + }); + const response = await fetch(url); + + deepStrictEqual(await response.text(), "replied"); + deepStrictEqual(await replied, { code: error, started: false }); + }; + + // Closed (without an error) before its end + const closed = new Readable({ + read(): void { + this.destroy(); + }, + }); + await check(closed, null, "ERR_STREAM_PREMATURE_CLOSE"); + // More than its `Content-Length` at once + await check(Readable.from(["abc"]), 2, "ERR_HTTP_CONTENT_LENGTH_MISMATCH"); + }); + + test(".send() (a `Readable` that fails after a part of the body cuts the response)", async () => { + const check = async ( + source: Readable, + length: number | null, + error: string, + ): Promise => { + const settled = new Promise((resolve) => { + server.once("request", (_request, response) => { + if (length !== null) { + response.setHeader("Content-Length", length); + } + resolve(settle(response.send(source))); + }); + }); + // The truncated body is not complete for the client (which may not + // even get the headers) + const complete = await new Promise((resolve) => { + get(url, (response) => { + response.resume().once("close", () => { + resolve(response.complete); + }); + }).on("error", () => { + resolve(false); + }); + }); + + deepStrictEqual(complete, false); + deepStrictEqual(await settled, error); + }; + + let reads = 0; + const closed = new Readable({ + read(): void { + reads += 1; + if (reads === 1) { + this.push("partial-"); + } else { + this.destroy(); + } + }, + }); + await check(closed, null, "ERR_STREAM_PREMATURE_CLOSE"); + // Less than its `Content-Length` + await check(Readable.from(["abc"]), 10, "ERR_HTTP_CONTENT_LENGTH_MISMATCH"); + // More than its `Content-Length` (the rest would be the next response) + await check( + Readable.from(["ab", "cde"]), + 4, + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + ); + }); + test(".send() (`HEAD`: the headers of the body only)", async () => { // Node.js rejects any body of a response to `HEAD` with this option const strict = createServer( From 00296b96c893a3ee4a6ac88abccc95e87137927a Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 03:18:44 +0300 Subject: [PATCH 076/675] fix: set only the own headers of an object in response.set() in binden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `response.set()` iterated the headers with `for…in`, which includes inherited properties: a header on the prototype of the object (or on a polluted `Object.prototype`) was sent as well, also by `sendFile()`, which uses `set()`. Only the own properties are set now. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/response.ts | 6 +++--- packages/binden/test/response.test.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index 02c75e80..e12d1b73 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -58,10 +58,10 @@ export class BindenResponse< return this; } - /** Set headers */ + /** Set headers: the own properties of the object (not inherited ones) */ public set(headers: IHeaders): this { - for (const name in headers) { - this.setHeader(name, headers[name]); + for (const [name, value] of Object.entries(headers)) { + this.setHeader(name, value); } return this; } diff --git a/packages/binden/test/response.test.ts b/packages/binden/test/response.test.ts index 1d10b56e..b83f1d34 100644 --- a/packages/binden/test/response.test.ts +++ b/packages/binden/test/response.test.ts @@ -216,6 +216,22 @@ suite("BindenResponse", () => { deepStrictEqual(response.headers.get(z), headers[z].join(", ")); }); + test(".set() (own properties only)", async () => { + // E.g. a polluted `Object.prototype` + const headers = Object.assign( + Object.create({ "X-Inherited": "1" }) as Record, + { "X-Own": "2" }, + ); + server.once("request", (_request, response) => { + response.set(headers).end(); + }); + const response = await fetch(url); + await response.text(); + + deepStrictEqual(response.headers.get("X-Own"), "2"); + deepStrictEqual(response.headers.get("X-Inherited"), null); + }); + test(".send()", async () => { const cookie1 = new Cookie({ key: "__Secure-K1", value: "V1" }); const cookie2 = new Cookie({ key: "__Host-K2", value: "V2" }); From 9d8f95ae51897f308d4a182f880713de67645be1 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 03:19:56 +0300 Subject: [PATCH 077/675] fix: keep the context of the request in binden A middleware may return a context to replace the current one, and any `Context` was taken: a context kept from a previous request (already done, with its response sent) made the stack stop there, and the response of the current request was never sent, until the client gave up. A returned context is now taken only when it has the request and the response of the current one. Otherwise the middleware fails with a `TypeError`, which the error handler replies to on the context of the request. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/binden.ts | 14 +++++++++++++- packages/binden/test/binden.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index 138b0b65..f937d84d 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -381,7 +381,19 @@ export class Binden { typeof middleware === "function" ? await middleware(context) : await middleware.run(context); - return next instanceof Context ? next : context; + if (!(next instanceof Context)) { + return context; + } else if ( + next.request !== context.request || + next.response !== context.response + ) { + // E.g. a context kept from a previous request: its response is not + // the one to reply with + throw new TypeError( + "A middleware returned the context of another request", + ); + } + return next; } catch (error: unknown) { if (middleware instanceof Middleware && middleware.ignore_errors) { const extra = { error, name }; diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index ba780956..f56559a1 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -682,6 +682,35 @@ suite("Binden", () => { ); }); + test("a context of another request is not taken over", async () => { + let first: Context | null = null; + app.use((context: Context) => { + if (first === null) { + first = context; + return context.send("first"); + } + // A replacement must be a context of the same request + return first; + }); + const signal = AbortSignal.timeout(5000); + + deepStrictEqual(await (await fetch(url, { signal })).text(), "first"); + const second = await fetch(url, { signal }); + deepStrictEqual(second.status, 500); + await second.text(); + }); + + test("a context of the same request may replace the context", async () => { + app.use( + (context: Context) => + new Context({ request: context.request, response: context.response }), + (context: Context) => context.send("replaced"), + ); + + const response = await fetch(url, { signal: AbortSignal.timeout(5000) }); + deepStrictEqual(await response.text(), "replaced"); + }); + test("a response ended without `done` is not cut by a 404", async () => { const size = 32 * 1024 * 1024; app.use((context: Context) => { From 9c91a530016109077069d6f51eeb997986381dd8 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 03:24:16 +0300 Subject: [PATCH 078/675] fix: reply to any thrown value in binden The default error reply read `status` (and `expose`, `json`, `message`) of the thrown value without any guard, so that `throw null`, `throw undefined`, a `status` that is a symbol, or a throwing getter made the error handler itself fail, and the client got its connection reset instead of a 500. The reply was sent with `end()`, without the cookies queued on the response (a 401 lost its `Set-Cookie`). A custom `error_handler` that started a reply without ending it (e.g. a `write()`) left the request pending. The properties of the thrown value are now read safely, so that any value gets a reply (500 when it has no valid status). The default reply is sent with `send()`, like any other response, with its cookies and framed by its status, and a reply that cannot be sent (e.g. the client went away) is logged. A reply that an `error_handler` has not ended once it settles is cut by closing the connection. There is still no deadline: a handler that never settles keeps its request. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/binden.ts | 101 +++++++++++++++++----------- packages/binden/src/logger.ts | 10 +-- packages/binden/src/property.ts | 11 +++ packages/binden/test/binden.test.ts | 92 +++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 50 deletions(-) create mode 100644 packages/binden/src/property.ts diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index f937d84d..78426f2d 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -6,8 +6,9 @@ import { Context } from "./context.js"; import { BindenError } from "./error.js"; import defaultLogger from "./logger.js"; import { Middleware, type IMiddleware } from "./middleware.js"; +import { property } from "./property.js"; import { BindenRequest, type ITrustProxy } from "./request.js"; -import { BindenResponse, ct_text, ct_json } from "./response.js"; +import { BindenResponse, ct_json } from "./response.js"; import { Router } from "./router.js"; type IUseOptions = IStackItem | RegExp | string | null | undefined; @@ -289,17 +290,37 @@ export class Binden { await this.#error_handler(context, error); - if (context.response.started) { + const { request, response } = context; + if (response.writableEnded) { + return; + } else if (response.started) { + // A reply that is not ended once the handler has settled (e.g. a + // `write()` without `end()`) would leave the client waiting + const unfinished = + "The provided `error_handler` has not ended its reply"; + Binden.#log( + context, + "warn", + `${unfinished}. Destroying the socket`, + base, + ); + request.destroy(); return; } const ignored = "The provided `error_handler` has not replied"; Binden.#log(context, "debug", ignored, base); } - Binden.#defaultErrorHandler(context, error); + await Binden.#defaultErrorHandler(context, error); } - /** Default reply to an error */ - static #defaultErrorHandler(context: Context, error: unknown): void { + /** + * Default reply to an error, which may be any thrown value: its properties + * are read without trusting them (e.g. `throw null`, or a throwing getter) + */ + static async #defaultErrorHandler( + context: Context, + error: unknown, + ): Promise { const { response, request } = context; // Only the context and the error, whose serializers keep the input out of the logs const base = { context, error }; @@ -309,59 +330,57 @@ export class Binden { if (response.writableEnded) { // Sent (or being sent): destroying the socket would cut it log("debug", "`.end()` has been called. Leaving the response"); + return; } else if (response.started) { // Part of the body may be sent (or compressed): ending the response would // make a truncated body look complete log("debug", "The response has been started. Destroying the socket"); - request.destroy(); - } else { - // The framing and representation of the body that is not sent - for (const name of Binden.#representation) { - response.removeHeader(name); - } - - const errorStatus = Number((error as { status?: unknown }).status); - - const status = - typeof STATUS_CODES[errorStatus] === "undefined" - ? DefaultErrorCode - : errorStatus; + return; + } - response.status(status); + // The framing and representation of the body that is not sent + for (const name of Binden.#representation) { + response.removeHeader(name); + } - const expose = Boolean((error as { expose?: unknown }).expose); + const raw = property(error, "status"); + const errorStatus = + typeof raw === "number" || typeof raw === "string" ? Number(raw) : NaN; + response.status( + typeof STATUS_CODES[errorStatus] === "undefined" + ? DefaultErrorCode + : errorStatus, + ); + // Sent like any other response: with its cookies, framed by its status + const reply = (): Promise => { + const expose = Boolean(property(error, "expose")); + const json = property(error, "json"); + const message = property(error, "message"); if (!expose) { log("debug", "Error should not be exposed"); - response.end(); - return; - } - - const { json } = error as { json?: Record }; - - if (json) { + return response.send(); + } else if (typeof json === "object" && json !== null) { try { - const message = JSON.stringify(json); - response.setHeader("Content-Type", ct_json).end(message); + const body = JSON.stringify(json); + return response.setHeader("Content-Type", ct_json).send(body); } catch { log("warn", "`JSON.stringify` failed"); - response.end(); + return response.send(); } - return; - } - - const { message } = error as { message?: unknown }; - - if (typeof message !== "string" || !message) { + } else if (typeof message !== "string" || !message) { log("debug", "`message` is not a valid string"); - response.end(); - return; - } else if (typeof response.getHeader("Content-Type") === "undefined") { - response.setHeader("Content-Type", ct_text); + return response.send(); } + return response.send(message); + }; - response.end(message); + try { + await reply(); + } catch { + // E.g. the client went away + log("debug", "The error reply has not been sent"); } } diff --git a/packages/binden/src/logger.ts b/packages/binden/src/logger.ts index 4f68ac52..1e60e3c6 100644 --- a/packages/binden/src/logger.ts +++ b/packages/binden/src/logger.ts @@ -3,6 +3,7 @@ import { constants } from "node:os"; import { Logger } from "@binden/logger"; import { Context } from "./context.js"; import { BindenError } from "./error.js"; +import { property as get } from "./property.js"; /** The request headers that the default logger keeps (others may carry credentials) */ const requestHeaders = ["host", "user-agent", "content-type", "content-length"]; @@ -37,15 +38,6 @@ const errorCodes: ReadonlySet = new Set([ "ERR_STREAM_WRITE_AFTER_END", ]); -/** Reads a property, so that a throwing getter (or proxy) cannot break the logs */ -function get(value: object, key: string): unknown { - try { - return Reflect.get(value, key) as unknown; - } catch { - return null; - } -} - /** The name of the nearest known error class of a value (never one it defines itself) */ function typeOf(value: object): string { try { diff --git a/packages/binden/src/property.ts b/packages/binden/src/property.ts new file mode 100644 index 00000000..a2c53033 --- /dev/null +++ b/packages/binden/src/property.ts @@ -0,0 +1,11 @@ +/** + * Reads a property of any thrown value: a throwing getter, a revoked proxy, + * `null` or another primitive gives `null` instead of an error + */ +export function property(value: unknown, key: string): unknown { + try { + return Reflect.get(value as object, key) as unknown; + } catch { + return null; + } +} diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index f56559a1..7215d94e 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -19,6 +19,7 @@ import { Middleware, type IMiddlewareParams, Context, + Cookie, Router, ct_text, ct_json, @@ -564,6 +565,97 @@ suite("Binden", () => { } }); + test("replies 500 to any thrown value", async () => { + const thrown: Partial unknown>> = { + "/null": () => null, + // `undefined` + "/undefined": (): unknown => Reflect.get({}, "nothing"), + "/string": () => "error", + "/symbol": () => ({ status: Symbol("status") }), + "/getter": () => ({ + get status(): never { + throw new Error("A throwing getter"); + }, + }), + }; + app.use((context: Context) => { + throw thrown[context.url.pathname]?.(); + }); + + for (const path of Object.keys(thrown)) { + const response = await fetch(`${url}${path}`, { + signal: AbortSignal.timeout(5000), + }); + deepStrictEqual(response.status, 500, path); + deepStrictEqual(await response.text(), "", path); + } + }); + + test("sends the cookies of the response with an error reply", async () => { + app.use((context: Context) => { + context.response.cookies.add(new Cookie({ key: "session", value: "1" })); + throw new BindenError(401, { expose: true }); + }); + + const response = await fetch(url, { signal: AbortSignal.timeout(5000) }); + + deepStrictEqual(response.status, 401); + ok(response.headers.get("Set-Cookie")?.startsWith("session=1;") === true); + deepStrictEqual(await response.text(), "Unauthorized"); + }); + + test("closes the connection when the `error_handler` does not end its reply", async () => { + const logged = await loggedApp({ + error_handler: (context: Context): void => { + context.response.write("partial"); + }, + }); + try { + logged.app.use(() => { + throw new BindenError(400); + }); + + // The truncated body is not taken as complete (and not left open): the + // client may not even get the headers + const outcome = await fetch(logged.url, { + signal: AbortSignal.timeout(5000), + }) + .then(async (response) => response.text()) + .then( + () => "complete", + (error: unknown) => (error as Error).name, + ); + ok(outcome !== "complete" && outcome !== "AbortError", outcome); + ok(logged.output().includes("has not ended its reply"), logged.output()); + } finally { + await logged.close(); + } + }); + + test("logs an error reply that cannot be sent", async () => { + const logged = await loggedApp(); + try { + logged.app.use((context: Context) => { + context.request.destroy(); + throw new BindenError(400); + }); + + await rejects(fetch(logged.url, { signal: AbortSignal.timeout(5000) })); + const deadline = Date.now() + 5000; + while ( + !logged.output().includes("The error reply has not been sent") && + Date.now() < deadline + ) { + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + } + ok(logged.output().includes("The error reply has not been sent")); + } finally { + await logged.close(); + } + }); + test("Disabled middleware", async () => { const m1 = new SendMiddleware({ disabled: true }); const m2 = new SendMiddleware(); From c163c71e290d2015804c5d6539d9660bc0280814 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 03:25:59 +0300 Subject: [PATCH 079/675] fix: never let a failing logger.child() crash the process in binden A `Context` created the child of its logger in its constructor, which the request listener of `Binden` calls outside of its promise chain: a custom logger whose `child()` throws crashed the process with the first request. The child is now created when the logs of the context are first used, and a logger that fails to create one is used itself, so that the request is handled as usual. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/context.ts | 19 +++++-- .../binden/test/fixtures/throwing-logger.ts | 52 +++++++++++++++++++ packages/binden/test/process.test.ts | 8 +++ 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 packages/binden/test/fixtures/throwing-logger.ts diff --git a/packages/binden/src/context.ts b/packages/binden/src/context.ts index ef433315..40465568 100644 --- a/packages/binden/src/context.ts +++ b/packages/binden/src/context.ts @@ -19,7 +19,8 @@ export interface IContextOptions extends IBaseContext { export class Context implements IBaseContext { readonly #request: BindenRequest; readonly #response: BindenResponse; - readonly #logger: Logger; + readonly #parent: Logger; + #logger?: Logger; #done: boolean; public constructor({ @@ -30,11 +31,23 @@ export class Context implements IBaseContext { this.#request = request; this.#response = response; this.#done = false; - const { id: trace_id } = request; - this.#logger = logger.child({ trace_id }); + this.#parent = logger; } + /** + * A child of the logger with the `trace_id` of the context, created when it + * is first used. A logger that fails to create one (e.g. a custom logger + * whose `child()` throws) is used itself: logging never prevents the context + * from being created. + */ public get log(): Logger { + if (!this.#logger) { + try { + this.#logger = this.#parent.child({ trace_id: this.id }); + } catch { + this.#logger = this.#parent; + } + } return this.#logger; } diff --git a/packages/binden/test/fixtures/throwing-logger.ts b/packages/binden/test/fixtures/throwing-logger.ts new file mode 100644 index 00000000..0915bdd1 --- /dev/null +++ b/packages/binden/test/fixtures/throwing-logger.ts @@ -0,0 +1,52 @@ +// Run by `process.test.ts`: a custom logger whose `child()` throws must not +// crash the process, nor keep the request from being handled +import { type IncomingMessage, request } from "node:http"; +import type { AddressInfo } from "node:net"; +import { once } from "node:events"; +import { Logger } from "@binden/logger"; +import { Binden } from "../../index.js"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +class ThrowingLogger extends Logger { + // eslint-disable-next-line class-methods-use-this -- a logger that always fails + public override child(): Logger { + throw new Error("The logger cannot create a child"); + } +} + +const lines: string[] = []; +const logger = new ThrowingLogger({ + destination: { + write(line: string): void { + lines.push(line); + }, + }, + level: "info", +}); + +const app = new Binden({ logger }).use((context) => { + context.log.info("Handled"); + return context.send("ok"); +}); + +const server = app.createServer().listen(0); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const response = await new Promise((resolve, reject) => { + request({ agent: false, port }, resolve).on("error", reject).end(); +}); +let body = ""; +response.setEncoding("utf8").on("data", (chunk: string) => { + body += chunk; +}); +await once(response, "end"); +server.close(); + +// The logs of the context go to the logger itself +const handled = lines.some((line) => line.includes("Handled")); +process.stdout.write(`${response.statusCode} ${body} ${handled}\n`); diff --git a/packages/binden/test/process.test.ts b/packages/binden/test/process.test.ts index bb83aa89..8c9d4724 100644 --- a/packages/binden/test/process.test.ts +++ b/packages/binden/test/process.test.ts @@ -25,4 +25,12 @@ suite("Process safety", () => { stderr: "", }); }); + + test("a logger whose `child()` throws does not crash the process", () => { + deepStrictEqual(run("throwing-logger.ts"), { + status: 0, + stdout: "200 ok true\n", + stderr: "", + }); + }); }); From 679ffc8a1ae8d5a529ecb48ed04baa852b3ea618 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 03:28:14 +0300 Subject: [PATCH 080/675] fix!: accept every status code from 100 to 599 in binden `response.status()` and `BindenError` accepted only the status codes that Node.js names: valid codes such as 430, 499 or 599 (RFC 9110, section 15 allows any three-digit code from 100 to 599) threw a `TypeError`, and the default error reply turned them into 500. `BindenError` accepted a string such as `"400"`, and kept it as its `status`. A status code is now any integer from 100 to 599, in `status()`, in the default error reply and in the logs; `BindenError` still requires one from 400 to 599. Anything else (also a string) throws a `TypeError`. BREAKING CHANGE: `response.status()` and `new BindenError()` throw a `TypeError` for a status code that is not an integer, such as `"400"`. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/binden.ts | 9 +++------ packages/binden/src/error.ts | 6 +++--- packages/binden/src/logger.ts | 7 +++---- packages/binden/src/response.ts | 7 ++++--- packages/binden/src/status.ts | 9 +++++++++ packages/binden/test/binden.test.ts | 11 +++++++++++ packages/binden/test/error.test.ts | 14 +++++++++++++- packages/binden/test/logger.test.ts | 5 +++++ packages/binden/test/response.test.ts | 16 +++++++++++----- 9 files changed, 62 insertions(+), 22 deletions(-) create mode 100644 packages/binden/src/status.ts diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index 78426f2d..77b62f54 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -1,4 +1,4 @@ -import http, { STATUS_CODES } from "node:http"; +import http from "node:http"; import https from "node:https"; import { isDeepStrictEqual } from "node:util"; import type { Logger } from "@binden/logger"; @@ -10,6 +10,7 @@ import { property } from "./property.js"; import { BindenRequest, type ITrustProxy } from "./request.js"; import { BindenResponse, ct_json } from "./response.js"; import { Router } from "./router.js"; +import { isStatus } from "./status.js"; type IUseOptions = IStackItem | RegExp | string | null | undefined; @@ -347,11 +348,7 @@ export class Binden { const raw = property(error, "status"); const errorStatus = typeof raw === "number" || typeof raw === "string" ? Number(raw) : NaN; - response.status( - typeof STATUS_CODES[errorStatus] === "undefined" - ? DefaultErrorCode - : errorStatus, - ); + response.status(isStatus(errorStatus) ? errorStatus : DefaultErrorCode); // Sent like any other response: with its cookies, framed by its status const reply = (): Promise => { diff --git a/packages/binden/src/error.ts b/packages/binden/src/error.ts index 68b7f1a4..de4e7202 100644 --- a/packages/binden/src/error.ts +++ b/packages/binden/src/error.ts @@ -20,10 +20,10 @@ export class BindenError extends Error { cause, }: IBindenErrorOptions = {}, ) { - if (status > 599) { - throw new RangeError("Status code is greater than 599"); - } else if (typeof STATUS_CODES[status] === "undefined") { + if (!Number.isInteger(status)) { throw new TypeError("Invalid status code"); + } else if (status > 599) { + throw new RangeError("Status code is greater than 599"); } else if (status < 400) { throw new RangeError("Status code is less than 400"); } diff --git a/packages/binden/src/logger.ts b/packages/binden/src/logger.ts index 1e60e3c6..cfd0f4e8 100644 --- a/packages/binden/src/logger.ts +++ b/packages/binden/src/logger.ts @@ -1,9 +1,10 @@ -import { IncomingMessage, ServerResponse, STATUS_CODES } from "node:http"; +import { IncomingMessage, ServerResponse } from "node:http"; import { constants } from "node:os"; import { Logger } from "@binden/logger"; import { Context } from "./context.js"; import { BindenError } from "./error.js"; import { property as get } from "./property.js"; +import { isStatus } from "./status.js"; /** The request headers that the default logger keeps (others may carry credentials) */ const requestHeaders = ["host", "user-agent", "content-type", "content-length"]; @@ -90,9 +91,7 @@ export const serializers = { const code = get(error, "code"); return { type: typeOf(error), - ...(typeof status === "number" && typeof STATUS_CODES[status] === "string" - ? { status } - : {}), + ...(isStatus(status) ? { status } : {}), ...(typeof expose === "boolean" ? { expose } : {}), ...(typeof code === "string" && errorCodes.has(code) ? { code } : {}), }; diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index e12d1b73..02f2e783 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -1,6 +1,6 @@ import { createReadStream, Stats } from "node:fs"; import { stat } from "node:fs/promises"; -import { IncomingMessage, ServerResponse, STATUS_CODES } from "node:http"; +import { IncomingMessage, ServerResponse } from "node:http"; import { Readable } from "node:stream"; import { pathToFileURL } from "node:url"; import ContentRange from "./headers/content-range.js"; @@ -9,6 +9,7 @@ import IfModifiedSince from "./headers/if-modified-since.js"; import { parseHttpDate } from "./headers/http-date.js"; import Range from "./headers/range.js"; import { BindenRequest } from "./request.js"; +import { isStatus } from "./status.js"; export const ct_json = "application/json"; export const ct_text = "text/plain"; @@ -49,9 +50,9 @@ export class BindenResponse< return this.#started || this.headersSent || this.writableEnded; } - /** Set the status code */ + /** Set the status code: an integer from 100 to 599 */ public status(code: number): this { - if (typeof STATUS_CODES[code] === "undefined") { + if (!isStatus(code)) { throw new TypeError(`Status code ${code} is invalid`); } this.statusCode = code; diff --git a/packages/binden/src/status.ts b/packages/binden/src/status.ts new file mode 100644 index 00000000..723d954a --- /dev/null +++ b/packages/binden/src/status.ts @@ -0,0 +1,9 @@ +/** + * Whether a value is a status code: an integer from 100 to 599 (RFC 9110, + * section 15), registered or not (e.g. 499) + */ +export function isStatus(value: unknown): boolean { + return ( + Number.isInteger(value) && Number(value) >= 100 && Number(value) <= 599 + ); +} diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index 7215d94e..51054032 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -591,6 +591,17 @@ suite("Binden", () => { } }); + test("replies with an unregistered status code of an error", async () => { + app.use(() => { + throw new BindenError(499); + }); + + const response = await fetch(url, { signal: AbortSignal.timeout(5000) }); + + deepStrictEqual(response.status, 499); + await response.text(); + }); + test("sends the cookies of the response with an error reply", async () => { app.use((context: Context) => { context.response.cookies.add(new Cookie({ key: "session", value: "1" })); diff --git a/packages/binden/test/error.test.ts b/packages/binden/test/error.test.ts index 1d51b572..cf145ce4 100644 --- a/packages/binden/test/error.test.ts +++ b/packages/binden/test/error.test.ts @@ -53,6 +53,18 @@ suite("BindenError", () => { () => new BindenError(600), new RangeError("Status code is greater than 599"), ); - throws(() => new BindenError(499), new TypeError("Invalid status code")); + // Not an integer (e.g. a string from an untyped caller) + for (const status of [400.5, Number.NaN, "400"]) { + throws( + () => new BindenError(status as number), + new TypeError("Invalid status code"), + ); + } + }); + + test("constructor (an unregistered status code)", () => { + const error = new BindenError(499); + deepStrictEqual(error.status, 499); + deepStrictEqual(error.message, ""); }); }); diff --git a/packages/binden/test/logger.test.ts b/packages/binden/test/logger.test.ts index 5eb6b74d..6488ee4a 100644 --- a/packages/binden/test/logger.test.ts +++ b/packages/binden/test/logger.test.ts @@ -157,6 +157,11 @@ suite("serializers", () => { { type: "object", status: 400, expose: true }, ); deepStrictEqual(serializers.error({ status: 999 }), { type: "object" }); + deepStrictEqual(serializers.error({ status: "400" }), { type: "object" }); + deepStrictEqual(serializers.error({ status: 499 }), { + type: "object", + status: 499, + }); deepStrictEqual(serializers.error("secret-message"), { type: "string" }); deepStrictEqual(serializers.error(null), { type: "null" }); }); diff --git a/packages/binden/test/response.test.ts b/packages/binden/test/response.test.ts index b83f1d34..a3a76d9b 100644 --- a/packages/binden/test/response.test.ts +++ b/packages/binden/test/response.test.ts @@ -172,15 +172,21 @@ suite("BindenResponse", () => { }); test(".status() (throws TypeError with unsupported code)", async () => { - const status = 499; const serverPromise = new Promise((resolve, reject) => { server.once("request", (_request, response) => { try { - throws( - () => response.status(status), - new TypeError(`Status code ${status} is invalid`), - ); + for (const status of [99, 600, 200.5, Number.NaN, "200"]) { + throws( + () => response.status(status as number), + new TypeError(`Status code ${status} is invalid`), + ); + } deepStrictEqual(response.statusCode, 200); + // Any status code from 100 to 599, registered or not + for (const status of [100, 430, 499, 599]) { + deepStrictEqual(response.status(status).statusCode, status); + } + response.status(200); } catch (error) { reject(error); } finally { From 9600d13ad31b9f226e3c76e91f6ac28ee5dc2c12 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 03:32:59 +0300 Subject: [PATCH 081/675] fix!: parse the request-target strictly in binden `request.URL` resolved the request-target as a URL reference against the `Host`: an absolute-form target (`https://evil.test/x`) gave its own scheme, so that `request.secure` was `true` over plain HTTP, a network-path reference (`//evil.test/x`, or `/\evil.test/x`) replaced the host, dot-segments (`/a/../b`) were removed before routing, so that the request reached another path, and a fragment, an invalid percent-encoding (`/%zz`) or userinfo in the target were accepted. The URL is now assembled from its parts (RFC 9112, section 3.2): the protocol of the connection (`request.protocol`), the authority of an absolute-form target (`http` or `https`) or else of `Host`, and the path and query of the target, set as such. A target that is not in origin-form, absolute-form or `*` (for `OPTIONS`), or that has a fragment, a backslash, an invalid percent-encoding or a dot-segment (also percent-encoded), and an authority that is not a host and a port, get a `400 Bad Request`. `request.secure` is `request.protocol === "https:"`, as documented. BREAKING CHANGE: such request-targets get a `400 Bad Request`, and `request.secure` and the protocol of `request.URL` no longer follow the scheme of an absolute-form target. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/request.ts | 78 ++++++++++++++++++++--- packages/binden/test/binden.test.ts | 18 ++++++ packages/binden/test/request.test.ts | 92 ++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 8 deletions(-) diff --git a/packages/binden/src/request.ts b/packages/binden/src/request.ts index 65097d01..f1c7e28a 100644 --- a/packages/binden/src/request.ts +++ b/packages/binden/src/request.ts @@ -159,19 +159,81 @@ export class BindenRequest extends IncomingMessage { return [...this.#range]; } + /** Whether the protocol is `https:` (see `protocol`) */ public get secure(): boolean { - return this.URL.protocol === "https:"; + return this.protocol === "https:"; } - /** The URL of the request (a `400 Bad Request` error without a valid one) */ + /** + * The URL of the request: the protocol of the connection (see `protocol`), + * the authority of the request-target in absolute-form (otherwise of + * `Host`), and the path and query of the request-target (RFC 9112, section + * 3.2). A `400 Bad Request` error without a valid one. + */ public get URL(): URL { - const { protocol, url = "/", headers } = this; - const { host = "" } = headers; - const base = `${protocol}//${host}`; - // E.g. an invalid `Host` header - if (!URL.canParse(url, base)) { + const { authority, path, query } = BindenRequest.#target( + this.url ?? "/", + this.method ?? "GET", + ); + const host = authority ?? this.headers.host ?? ""; + const base = `${this.protocol}//${host}`; + // `uri-host [ ":" port ]`: nothing that ends the authority, nor userinfo + if (/[\s/?#@\\]/u.test(host) || !URL.canParse(base)) { + throw new BindenError(400); + } + const url = new URL(base); + // Set as a path: `//evil.test/x` is a path, not the authority of the URL + url.pathname = path; + url.search = query === "" ? "" : `?${query}`; + return url; + } + + /** + * The authority (of the absolute-form), the path and the query of a + * request-target, which must be in origin-form (or absolute-form with `http` + * or `https`, or `*` for `OPTIONS`), without a fragment, a backslash, an + * invalid percent-encoding, or dot-segments (which the URL parser would + * remove, so that the request would be routed to another path). + */ + static #target( + target: string, + method: string, + ): { authority: string | null; path: string; query: string } { + if (target === "*" && method === "OPTIONS") { + return { authority: null, path: "*", query: "" }; + } + + let authority: string | null = null; + let rest = target; + const absolute = + /^(?[a-z][\d+.a-z-]*):\/\/(?[^/?]*)(?.*)$/iu.exec( + target, + )?.groups; + if (absolute) { + const { scheme = "", host = "", tail = "" } = absolute; + // The authority is then checked as `Host` (e.g. for userinfo) + if (!["http", "https"].includes(scheme.toLowerCase()) || host === "") { + throw new BindenError(400); + } + authority = host; + // An empty path is `/` + rest = tail.startsWith("/") ? tail : `/${tail}`; + } + + if ( + !rest.startsWith("/") || + /[#\\]/u.test(rest) || + /%(?![\da-f]{2})/iu.test(rest) + ) { + throw new BindenError(400); + } + + const index = rest.indexOf("?"); + const path = index === -1 ? rest : rest.slice(0, index); + const query = index === -1 ? "" : rest.slice(index + 1); + if (path.split("/").some((part) => /^(?:\.|%2e){1,2}$/iu.test(part))) { throw new BindenError(400); } - return new URL(url, base); + return { authority, path, query }; } } diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index 51054032..52b8d333 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -720,6 +720,24 @@ suite("Binden", () => { deepStrictEqual(status, 400); }); + test("400 error (an invalid request-target)", async () => { + app.use("/b", (context: Context) => context.send("b")); + // Sent as it is (a URL would be normalized by the client) + const status = (path: string): Promise => + new Promise((resolve, reject) => { + httpRequest({ path, port: portOf(server) }, (response) => { + response.resume(); + resolve(response.statusCode); + }) + .on("error", reject) + .end(); + }); + + deepStrictEqual(await status("/b"), 200); + deepStrictEqual(await status("/a/../b"), 400); + deepStrictEqual(await status("/%zz"), 400); + }); + test("404 error (context.done === false)", async () => { app.use(new CustomMiddleware()); const response = await fetch(url); diff --git a/packages/binden/test/request.test.ts b/packages/binden/test/request.test.ts index ab893b12..f8383a2a 100644 --- a/packages/binden/test/request.test.ts +++ b/packages/binden/test/request.test.ts @@ -274,6 +274,98 @@ suite("BindenRequest", () => { ); }); + test(".URL (request-targets)", () => { + const create = ( + target: string, + method = "GET", + host = "trusted.test", + ): BindenRequest => { + const request = new BindenRequest(new Socket()); + request.url = target; + request.method = method; + request.headers = { host }; + return request; + }; + const valid: [string, string, string][] = [ + ["/p?a=1", "GET", "http://trusted.test/p?a=1"], + // A path, not the authority of a network-path reference + ["//evil.test/x", "GET", "http://trusted.test//evil.test/x"], + [ + "/a%20b/:@!$&'()*+,;=", + "GET", + "http://trusted.test/a%20b/:@!$&'()*+,;=", + ], + ["/a?b?c", "GET", "http://trusted.test/a?b?c"], + ["/a??b", "GET", "http://trusted.test/a??b"], + ["/a?", "GET", "http://trusted.test/a"], + ["/...", "GET", "http://trusted.test/..."], + // Absolute-form: its authority, but the protocol of the connection + ["https://evil.test/x?y", "GET", "http://evil.test/x?y"], + ["HTTP://evil.test:8080", "GET", "http://evil.test:8080/"], + ["http://evil.test?x=1", "GET", "http://evil.test/?x=1"], + ["*", "OPTIONS", "http://trusted.test/*"], + ]; + for (const [target, method, expected] of valid) { + deepStrictEqual(create(target, method).URL.href, expected, target); + } + deepStrictEqual(create("https://evil.test/x").secure, false); + // Of the connection: known without a valid URL + deepStrictEqual(create("/%zz", "GET", "a b").secure, false); + // Without a request-target (not parsed from a request): `/` + const bare = create("/"); + Reflect.deleteProperty(bare, "url"); + deepStrictEqual(bare.URL.href, "http://trusted.test/"); + // Without `Host` (e.g. HTTP/1.0) + const anonymous = create("/"); + anonymous.headers = {}; + throws( + () => anonymous.URL, + (error: unknown) => error instanceof BindenError && error.status === 400, + ); + + const invalid: [string, string, string][] = [ + ["/\\evil.test/x", "GET", "trusted.test"], + ["/%zz", "GET", "trusted.test"], + ["/a%2", "GET", "trusted.test"], + ["/a#b", "GET", "trusted.test"], + ["/a?b#c", "GET", "trusted.test"], + // Dot-segments (also percent-encoded) route to another path + ["/a/../b", "GET", "trusted.test"], + ["/a/./b", "GET", "trusted.test"], + ["/..", "GET", "trusted.test"], + ["/a/%2e%2E/b", "GET", "trusted.test"], + ["/a/.%2e", "GET", "trusted.test"], + ["http://user:pw@trusted.test/x", "GET", "trusted.test"], + ["http://user@trusted.test/x", "GET", "trusted.test"], + ["ftp://trusted.test/x", "GET", "trusted.test"], + ["http:///x", "GET", "trusted.test"], + ["*", "GET", "trusted.test"], + ["x", "GET", "trusted.test"], + ["?a", "GET", "trusted.test"], + // `Host` is only a host and a port + ["/", "GET", "trusted.test/x"], + ["/", "GET", "user@trusted.test"], + ["/", "GET", "trusted.test#x"], + ["/", "GET", ""], + ]; + for (const [target, method, host] of invalid) { + throws( + () => create(target, method, host).URL, + (error: unknown) => + error instanceof BindenError && error.status === 400, + `${target} (Host: ${host})`, + ); + } + }); + + test(".secure (the protocol of the connection)", () => { + const tls = new TLSSocket(new Socket()); + const request = new BindenRequest(tls); + request.url = "http://trusted.test/"; + deepStrictEqual(request.secure, true); + tls.destroy(); + }); + test(".query (keeps its prototype)", () => { const request = new BindenRequest(new Socket()); request.url = "/?__proto__=a&__proto__=b&x=1"; From a52159ffca196277786afc4616d9f56b7fa69022 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 03:36:01 +0300 Subject: [PATCH 082/675] fix: count trusted Forwarded hops by their position in binden `request.protocol` took the `Forwarded` element of the outermost trusted proxy by its index in the parsed elements, but the parser dropped the elements that it did not understand (only extension parameters, an empty `for=""`, or anything malformed): with `trust_proxy: 1`, an element of the nearest proxy that was dropped let the element of the client take its place (`for=x;proto=https, garbage` was `https:`). A parameter given twice took its last value, and a comma in a quoted string split the element. `Forwarded.parse()` now returns the elements in their positions (split on the commas outside of quoted strings), with `null` for an invalid element: a parameter that is not `name=value`, or one given twice. `request.protocol` reads the element in the position of the trusted proxy, and keeps the protocol of the connection when that element is invalid or has no known `proto`. Values without spaces and separators are taken unquoted, as some proxies send IPv6 addresses and ports. `Forwarded.fromString()` returns the valid elements, now including those without a known parameter. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/headers/content-type.ts | 38 +--------- packages/binden/src/headers/forwarded.ts | 74 +++++++++++-------- packages/binden/src/headers/list.ts | 34 +++++++++ packages/binden/src/request.ts | 3 +- .../binden/test/headers/forwarded.test.ts | 26 ++++++- packages/binden/test/request.test.ts | 9 +++ 6 files changed, 115 insertions(+), 69 deletions(-) create mode 100644 packages/binden/src/headers/list.ts diff --git a/packages/binden/src/headers/content-type.ts b/packages/binden/src/headers/content-type.ts index 2c2869b1..0f7b2a27 100644 --- a/packages/binden/src/headers/content-type.ts +++ b/packages/binden/src/headers/content-type.ts @@ -1,3 +1,5 @@ +import { split, unquote } from "./list.js"; + export interface IContentType { type: string; charset?: string | null; @@ -45,7 +47,7 @@ export class ContentType implements IContentType { /** `type/subtype *( OWS ";" OWS parameter )` (RFC 9110, section 8.3.1) */ public static fromString(input?: string): ContentType | null { - const [rawType = "", ...rest] = ContentType.#split(input ?? ""); + const [rawType = "", ...rest] = split(input ?? "", ";"); const type = rawType.trim().toLowerCase(); if (!type) { @@ -59,7 +61,7 @@ export class ContentType implements IContentType { const name = parameter.slice(0, Math.max(index, 0)).trim().toLowerCase(); if (index > 0 && !parameters.has(name)) { const value = parameter.slice(index + 1).trim(); - parameters.set(name, ContentType.#unquote(value)); + parameters.set(name, unquote(value)); } } @@ -72,38 +74,6 @@ export class ContentType implements IContentType { return new ContentType({ type, charset }); } - /** Splits on the `;` that are not in a quoted string */ - static #split(input: string): string[] { - const parts: string[] = []; - let part = ""; - let quoted = false; - let escaped = false; - for (const char of input) { - if (char === ";" && !quoted) { - parts.push(part); - part = ""; - } else { - part += char; - if (escaped) { - escaped = false; - } else if (quoted && char === "\\") { - escaped = true; - } else if (char === `"`) { - quoted = !quoted; - } - } - } - parts.push(part); - return parts; - } - - /** The value of a quoted string (RFC 9110, section 5.6.4) */ - static #unquote(value: string): string { - return value.length >= 2 && value.startsWith(`"`) && value.endsWith(`"`) - ? value.slice(1, -1).replaceAll(/\\(?.)/gu, "$") - : value; - } - /** A token as is, anything else as a quoted string */ static #quote(value: string): string { return /^[!#$%&'*+.^_`|~\w-]+$/u.test(value) diff --git a/packages/binden/src/headers/forwarded.ts b/packages/binden/src/headers/forwarded.ts index 30cc45fb..6771e756 100644 --- a/packages/binden/src/headers/forwarded.ts +++ b/packages/binden/src/headers/forwarded.ts @@ -1,3 +1,5 @@ +import { split, unquote } from "./list.js"; + export interface IForwarded { for: string; by?: string | null; @@ -55,47 +57,55 @@ export class Forwarded implements IForwarded { return output; } + /** The valid elements of the header (see `.parse()`) */ public static fromString(input?: string): Forwarded[] { - if (typeof input === "undefined" || input.length === 0) { + return Forwarded.parse(input).filter( + (element): element is Forwarded => element !== null, + ); + } + + /** + * The elements of the header in their order, `null` for an invalid one + * (RFC 7239, section 4): a parameter that is not `name=value` (a quoted + * string, or a value without spaces and separators, also the unquoted IPv6 + * addresses and ports that some proxies send), or that occurs more than + * once. Empty elements are ignored (RFC 9110, section 5.6.1). Their position + * tells which proxy has added them: every proxy appends its own. + */ + public static parse(input?: string): (Forwarded | null)[] { + if (typeof input === "undefined") { return []; } - - return input - .split(",") - .map((e) => Forwarded.#parseDirectives(e)) - .filter((e): e is IForwarded => Boolean(e)) - .map((e) => new Forwarded(e)); + return split(input, ",") + .filter((element) => !/^[\s;]*$/u.test(element)) + .map((element) => Forwarded.#parseElement(element)); } static #formatDirective(input: string): string { return input.startsWith("[") && input.includes("]") ? `"${input}"` : input; } - static #parseDirectives(input: string): IForwarded | null { - const props = ["for", "by", "secret", "host", "proto"]; - - const directives = input - .split(";") - .map((e) => e.split("=")) - .map<[string, string]>(([k, v]) => [ - k.trim(), - (v as string | undefined)?.trim() ?? "", - ]) - .map<[string, string]>(([k, v]) => [ - k.toLowerCase(), - v.startsWith(`"`) && v.endsWith(`"`) - ? v.length > 2 - ? v.substring(1, v.length - 1) - : "" - : v, - ]) - .filter(([k, v]) => k && v && props.includes(k)); - - return directives.length - ? directives.reduce((a, [k, v]) => ({ ...a, [k]: v }), { - for: "unknown", - }) - : null; + static #parseElement(input: string): Forwarded | null { + const parameters = new Map(); + const pairs = split(input, ";").filter((pair) => pair.trim() !== ""); + for (const pair of pairs) { + const { name = "", value = "" } = + /^\s*(?[!#$%&'*+.^_`|~\w-]+)\s*=\s*(?[^\s",;]+|"(?:[^"\\]|\\.)*")\s*$/u.exec( + pair, + )?.groups ?? {}; + const key = name.toLowerCase(); + if (key === "" || parameters.has(key)) { + return null; + } + parameters.set(key, unquote(value)); + } + return new Forwarded({ + for: parameters.get("for") ?? "unknown", + by: parameters.get("by") ?? null, + host: parameters.get("host") ?? null, + proto: parameters.get("proto") ?? null, + secret: parameters.get("secret") ?? null, + }); } } diff --git a/packages/binden/src/headers/list.ts b/packages/binden/src/headers/list.ts new file mode 100644 index 00000000..4f035b9f --- /dev/null +++ b/packages/binden/src/headers/list.ts @@ -0,0 +1,34 @@ +/** + * Splits a field value on the separators (e.g. `,` of a list, or `;` of + * parameters) that are not in a quoted string (RFC 9110, section 5.6.4) + */ +export function split(input: string, separator: string): string[] { + const parts: string[] = []; + let part = ""; + let quoted = false; + let escaped = false; + for (const char of input) { + if (char === separator && !quoted) { + parts.push(part); + part = ""; + } else { + part += char; + if (escaped) { + escaped = false; + } else if (quoted && char === "\\") { + escaped = true; + } else if (char === `"`) { + quoted = !quoted; + } + } + } + parts.push(part); + return parts; +} + +/** The value of a quoted string (RFC 9110, section 5.6.4), or the value itself */ +export function unquote(value: string): string { + return value.length >= 2 && value.startsWith(`"`) && value.endsWith(`"`) + ? value.slice(1, -1).replaceAll(/\\(?.)/gu, "$") + : value; +} diff --git a/packages/binden/src/request.ts b/packages/binden/src/request.ts index f1c7e28a..2b254c88 100644 --- a/packages/binden/src/request.ts +++ b/packages/binden/src/request.ts @@ -118,7 +118,8 @@ export class BindenRequest extends IncomingMessage { const tls: IProtocol = this.socket instanceof TLSSocket ? "https:" : "http:"; const trust = this.trust_proxy; - const { forwarded } = this; + // In their positions: an element that is not valid is not skipped + const forwarded = Forwarded.parse(this.headers.forwarded); if (trust === false || trust === 0 || forwarded.length === 0) { return tls; } diff --git a/packages/binden/test/headers/forwarded.test.ts b/packages/binden/test/headers/forwarded.test.ts index e837ef87..ad11c4f8 100644 --- a/packages/binden/test/headers/forwarded.test.ts +++ b/packages/binden/test/headers/forwarded.test.ts @@ -40,9 +40,13 @@ suite("Forwarded", () => { const parsed = Forwarded.fromString(input); - deepStrictEqual(parsed.length, 2); + // `ignored=1` and `for=""` are valid elements, while `ignored` (not a + // parameter) and `for="` (an unterminated quoted string) are not + deepStrictEqual(parsed.length, 4); - const [one, two] = parsed; + const [one, two, extension, empty] = parsed; + deepStrictEqual(extension.toString(), "for=unknown"); + deepStrictEqual(empty.for, ""); deepStrictEqual(one.for, forwarded1.for); deepStrictEqual(one.by, forwarded1.by); @@ -59,6 +63,24 @@ suite("Forwarded", () => { deepStrictEqual(two.toString(), forwarded2.toString()); }); + test(".parse() (the elements in their positions)", () => { + const parse = (input?: string): (string | null)[] => + Forwarded.parse(input).map((element) => element?.toString() ?? null); + + deepStrictEqual(parse(), []); + deepStrictEqual( + parse(`for=a;proto=https, garbage, for="b,c", ext=1, , for=d;for=e`), + ["for=a;proto=https", null, "for=b,c", "for=unknown", null], + ); + // The values that some proxies send unquoted + deepStrictEqual(parse("for=2001:db8::1;proto=https, for=[::1]:80"), [ + "for=2001:db8::1;proto=https", + `for="[::1]:80"`, + ]); + // A repeated parameter, also in another case, is ambiguous + deepStrictEqual(parse("proto=http;PROTO=https"), [null]); + }); + test(".fromString() (no `for` directive)", () => { const secret = "donottellanyone"; const input = `Secret = donottellanyone ; , ; , `; diff --git a/packages/binden/test/request.test.ts b/packages/binden/test/request.test.ts index f8383a2a..3e24931d 100644 --- a/packages/binden/test/request.test.ts +++ b/packages/binden/test/request.test.ts @@ -412,6 +412,15 @@ suite("BindenRequest", () => { deepStrictEqual(create(true).protocol, "http:"); deepStrictEqual(create(true, "for=1.2.3.4").protocol, "http:"); deepStrictEqual(create(true, "for=1.2.3.4;proto=ftp").protocol, "http:"); + // The trusted element is the one in its position: an invalid element of a + // proxy does not let the one of the client take its place + deepStrictEqual(create(1, "for=x;proto=https, garbage").protocol, "http:"); + deepStrictEqual(create(1, "proto=https, ext=1").protocol, "http:"); + deepStrictEqual(create(1, `proto=https, for=""`).protocol, "http:"); + deepStrictEqual(create(1, `proto=https, for="a,b"`).protocol, "http:"); + deepStrictEqual(create(1, "proto=http;proto=https").protocol, "http:"); + // Empty elements are not elements + deepStrictEqual(create(1, "proto=https, , ").protocol, "https:"); // Over TLS, a trusted element without `proto` keeps `https:` const tls = new TLSSocket(new Socket()); deepStrictEqual(create(true, "for=1.2.3.4", tls).protocol, "https:"); From 34994b40ef278f53e3839592226beff402aaf457 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 03:37:01 +0300 Subject: [PATCH 083/675] fix: prefer an explicit HEAD handler in binden With `auto_head` (the default), a `HEAD` request was always handled by the `GET` middlewares of a router: the middlewares of `router.head()` never ran, and a router with only `HEAD` middlewares answered `HEAD` with 404. The `GET` middlewares now handle `HEAD` only for a router that has no `HEAD` middlewares of its own. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/binden.ts | 5 ++++- packages/binden/test/binden.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index 77b62f54..60fc043a 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -230,6 +230,8 @@ export class Binden { if (item instanceof Router) { const { guarded, middlewares, methods } = item; const { method = "GET" } = next.request; + // `HEAD` is handled by the `GET` middlewares, unless it has its own + const head = methods.has("HEAD"); if (guarded && methods.size) { if (methods.has("GET") && this.#auto_head) { @@ -243,7 +245,8 @@ export class Binden { } } - const m = method === "HEAD" && this.#auto_head ? "GET" : method; + const m = + method === "HEAD" && this.#auto_head && !head ? "GET" : method; const methodMiddlewares = middlewares(m); for (const mw of methodMiddlewares) { diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index 52b8d333..f2e8be08 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -684,6 +684,32 @@ suite("Binden", () => { deepStrictEqual(response.status, 200); }); + test("auto_head (an explicit `HEAD` middleware)", async () => { + app.use( + "/both", + new Router() + .get((context: Context) => context.setHeader("X-Handler", "GET").send()) + .head((context: Context) => + context.setHeader("X-Handler", "HEAD").send(), + ), + ); + app.use( + "/head", + new Router({ guarded: true }).head((context: Context) => + context.setHeader("X-Handler", "HEAD").send(), + ), + ); + const handler = async (path: string, method: string): Promise => { + const response = await fetch(`${url}${path}`, { method }); + await response.text(); + return [response.status, response.headers.get("X-Handler")]; + }; + + deepStrictEqual(await handler("/both", "HEAD"), [200, "HEAD"]); + deepStrictEqual(await handler("/both", "GET"), [200, "GET"]); + deepStrictEqual(await handler("/head", "HEAD"), [200, "HEAD"]); + }); + test("function middlewares", async () => { const message = { message: "Hello World!" }; const m = (context: Context): Promise => context.json(message); From b89b41b492540bbb6e88a7d2a31585de504068fa Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 03:38:33 +0300 Subject: [PATCH 084/675] fix: keep the flags of RegExp paths in binden `app.use()` and `app.off()` replaced the flags of a RegExp path with `u`: `/^\/foo$/i` did not match `/FOO`, and a pattern that is not valid with `u` (such as `/\-/`) threw a `SyntaxError`. A path now keeps its flags, except `g` and `y`, which make `test()` stateful (every other request would not match). Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/binden.ts | 14 +++++++++++--- packages/binden/test/binden.test.ts | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index 60fc043a..c2618834 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -71,7 +71,7 @@ export class Binden { public get stack(): IStack[] { return this.#stack.map(([r, p]) => [ [...r], - p instanceof RegExp ? new RegExp(p, "u") : p, + p instanceof RegExp ? new RegExp(p.source, p.flags) : p, ]); } @@ -122,7 +122,7 @@ export class Binden { const path = _path instanceof RegExp - ? new RegExp(_path, "u") + ? Binden.#pattern(_path) : typeof _path === "string" && _path ? _path : null; @@ -157,7 +157,7 @@ export class Binden { const path = _path instanceof RegExp - ? new RegExp(_path, "u") + ? Binden.#pattern(_path) : typeof _path === "string" && _path ? _path : null; @@ -449,6 +449,14 @@ export class Binden { } } + /** + * A RegExp path with its flags (e.g. `i`), except those that make `test()` + * stateful (`g` and `y`) + */ + static #pattern(path: RegExp): RegExp { + return new RegExp(path.source, path.flags.replaceAll(/[gy]/gu, "")); + } + static #validateItem(item: unknown): item is IStackItem { return ( item instanceof Middleware || diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index f2e8be08..96cf93b9 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -710,6 +710,25 @@ suite("Binden", () => { deepStrictEqual(await handler("/head", "HEAD"), [200, "HEAD"]); }); + test("RegExp paths keep their flags", async () => { + // eslint-disable-next-line require-unicode-regexp -- without `u` on purpose + app.use(/^\/foo$/i, (context: Context) => context.send("foo")); + // `g` would make `test()` stateful: every other request would not match + app.use(/^\/bar$/gu, (context: Context) => context.send("bar")); + // Not valid with `u` + // eslint-disable-next-line require-unicode-regexp, no-useless-escape -- on purpose + app.use(/^\/b\-z$/, (context: Context) => context.send("b-z")); + const text = async (path: string): Promise => + (await fetch(`${url}${path}`)).text(); + + deepStrictEqual(await text("/FOO"), "foo"); + deepStrictEqual(await text("/bar"), "bar"); + deepStrictEqual(await text("/bar"), "bar"); + deepStrictEqual(await text("/b-z"), "b-z"); + // eslint-disable-next-line require-unicode-regexp -- the flags of the path + deepStrictEqual(app.stack[0]?.[1], /^\/foo$/i); + }); + test("function middlewares", async () => { const message = { message: "Hello World!" }; const m = (context: Context): Promise => context.json(message); From b46977a14ae292e82a0ad809a568dd734899c982 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 03:40:04 +0300 Subject: [PATCH 085/675] fix: try every guarded router before replying 405 in binden A guarded router replied 405 as soon as it did not allow the method of the request: a second router for the same path that allows it (for example, a `GET` router and a `POST` router) was never reached. A guarded router that does not allow the method now passes the request on, and 405 (with the methods of every guarded router that refused it in `Allow`) is the reply only when nothing else has handled the request. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/binden.ts | 34 +++++++++++++++++++---------- packages/binden/test/binden.test.ts | 32 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index c2618834..9c9e9e39 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -220,6 +220,8 @@ export class Binden { async #handle(context: Context): Promise { let next = context; + // The methods of the guarded routers that have refused the request + const allow = new Set(); for (const [items, path] of this.#stack) { const regExp = path instanceof RegExp && path.test(next.url.pathname); @@ -233,21 +235,27 @@ export class Binden { // `HEAD` is handled by the `GET` middlewares, unless it has its own const head = methods.has("HEAD"); - if (guarded && methods.size) { - if (methods.has("GET") && this.#auto_head) { - methods.add("HEAD"); - } - - if (!methods.has(method)) { - next.response.set({ Allow: `${[...methods].join(", ")}` }); + if ( + guarded && + methods.size && + methods.has("GET") && + this.#auto_head + ) { + methods.add("HEAD"); + } - throw new BindenError(405); + // A guarded router that does not allow the method: another one may + // (405 when none does) + const refused = guarded && methods.size > 0 && !methods.has(method); + if (refused) { + for (const allowed of methods) { + allow.add(allowed); } } const m = method === "HEAD" && this.#auto_head && !head ? "GET" : method; - const methodMiddlewares = middlewares(m); + const methodMiddlewares = refused ? [] : middlewares(m); for (const mw of methodMiddlewares) { next = await Binden.#runMiddleware(mw, next); @@ -271,9 +279,13 @@ export class Binden { // Nothing has handled the request, unless a middleware has started (or ended) // the response without setting `done`: it is left to finish - if (!next.response.started) { - throw new BindenError(404); + if (next.response.started) { + return; + } else if (allow.size) { + next.response.set({ Allow: [...allow].join(", ") }); + throw new BindenError(405); } + throw new BindenError(404); } /** A middleware has set `done`: a response that nothing has started is sent empty */ diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index 96cf93b9..bc22bde3 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -905,6 +905,38 @@ suite("Binden", () => { deepStrictEqual(response.headers.get("Allow"), "PATCH, GET, HEAD"); }); + test("guarded routers (another router allows the method)", async () => { + const get = new Router({ guarded: true }).get((context: Context) => + context.send("get"), + ); + const post = new Router({ guarded: true }).post((context: Context) => + context.send("post"), + ); + app.use("/one", get, post); + app.use("/two", get).use("/two", new SendMiddleware()); + app.use("/three", new Router({ guarded: true }).put(new SendMiddleware())); + app.use("/three", get); + const send = async ( + path: string, + method: string, + ): Promise<[number, string, string | null]> => { + const response = await fetch(`${url}${path}`, { method }); + const allow = response.headers.get("Allow"); + return [response.status, await response.text(), allow]; + }; + + deepStrictEqual(await send("/one", "GET"), [200, "get", null]); + deepStrictEqual(await send("/one", "POST"), [200, "post", null]); + // A middleware after the router may handle any method + deepStrictEqual((await send("/two", "POST"))[0], 200); + // None allows the method: 405 with the methods of every guarded router + deepStrictEqual(await send("/three", "DELETE"), [ + 405, + "", + "PUT, GET, HEAD", + ]); + }); + test("405 error (`guarded` router `auto_head === false`)", async () => { const newApp = new Binden({ auto_head: false }); const path = new URL(url); From 4f8ed79ad7f80f916442eb65e73cdea23ebd353c Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 03:40:47 +0300 Subject: [PATCH 086/675] fix!: reject CONNECT routes in binden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `router.on("CONNECT", …)` was accepted, but Node.js emits a `CONNECT` request as a `connect` event of the server, which never reaches the request listener: the middlewares never ran, and the client got its connection closed. `router.on()` and `router.off()` now throw a `TypeError` for `CONNECT`, as for any other method that Node.js does not route to them. BREAKING CHANGE: `router.on("CONNECT", …)` and `router.off("CONNECT", …)` throw a `TypeError`. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/router.ts | 12 ++++++++++-- packages/binden/test/router.test.ts | 9 +++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/binden/src/router.ts b/packages/binden/src/router.ts index 361ec209..e26df768 100644 --- a/packages/binden/src/router.ts +++ b/packages/binden/src/router.ts @@ -42,7 +42,7 @@ export class Router { } public on(method: string, ..._middlewares: IMiddleware[]): this { - if (!METHODS.includes(method)) { + if (!Router.#supports(method)) { throw new TypeError(`Method ${method} is not supported`); } else if (!_middlewares.length) { return this; @@ -68,7 +68,7 @@ export class Router { } public off(method: string, ..._middlewares: IMiddleware[]): IMiddleware[] { - if (!METHODS.includes(method)) { + if (!Router.#supports(method)) { throw new TypeError(`Method ${method} is not supported`); } @@ -126,4 +126,12 @@ export class Router { public trace(...middlewares: IMiddleware[]): this { return this.on("TRACE", ...middlewares); } + + /** + * The methods of Node.js, except `CONNECT`: Node.js emits its requests as + * `connect` events of the server, which never reach the middlewares + */ + static #supports(method: string): boolean { + return METHODS.includes(method) && method !== "CONNECT"; + } } diff --git a/packages/binden/test/router.test.ts b/packages/binden/test/router.test.ts index 637759a2..16f6cce1 100644 --- a/packages/binden/test/router.test.ts +++ b/packages/binden/test/router.test.ts @@ -66,6 +66,15 @@ suite("Router", () => { () => new Router().on(unsupportedMethod), new TypeError(`Method ${unsupportedMethod} is not supported`), ); + // Never reaches the middlewares (a `connect` event of the server) + throws( + () => new Router().on("CONNECT", one), + new TypeError("Method CONNECT is not supported"), + ); + throws( + () => new Router().off("CONNECT", one), + new TypeError("Method CONNECT is not supported"), + ); }); test(".on() (with unsupported middleware)", () => { From fca40d264f60df44a208343edb3dbed8da5c648a Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 03:48:52 +0300 Subject: [PATCH 087/675] fix!: read the metadata and the content of sendFile from one file handle in binden `sendFile()` read the size and the modification time of the file with `stat()`, and opened the file again to send it: a file that grew in between sent more bytes than its `Content-Length` (read by the client as the next response), one that shrank left the client waiting, and a path replaced in between sent another file under the metadata of the first. Stats passed as the second argument could be stale in the same way. Opening a FIFO waited for a writer. The file is now opened once (without waiting for a FIFO), its metadata is read from that handle, and its content is streamed from it, with no more bytes than its size. As the metadata now always comes from the file that is sent, the second argument is removed. BREAKING CHANGE: `response.sendFile()` takes the path only: the `Stats` argument is removed. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/response.ts | 140 +++++++++++++++----------- packages/binden/test/response.test.ts | 111 ++++++++++++++++---- 2 files changed, 170 insertions(+), 81 deletions(-) diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index 02f2e783..78224811 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -1,5 +1,5 @@ -import { createReadStream, Stats } from "node:fs"; -import { stat } from "node:fs/promises"; +import { constants } from "node:fs"; +import { open } from "node:fs/promises"; import { IncomingMessage, ServerResponse } from "node:http"; import { Readable } from "node:stream"; import { pathToFileURL } from "node:url"; @@ -403,86 +403,106 @@ export class BindenResponse< }); } - public async sendFile( - path: URL | string, - options?: Pick, - ): Promise { + /** + * Sends a file. Its metadata and its content are read from one file handle, + * so that they belong to the same file (also when the path is replaced), and + * no more than its size is sent. + */ + public async sendFile(path: URL | string): Promise { // Started at once (see `.started`), unless it fails before sending anything this.#started = true; try { - await this.#sendFile(path, options); + await this.#sendFile(path); } catch (error: unknown) { this.#started = false; throw error; } } - async #sendFile( - path: URL | string, - options?: Pick, - ): Promise { + async #sendFile(path: URL | string): Promise { const url = path instanceof URL ? new URL(path.href) : pathToFileURL(path); if (url.protocol !== "file:") { throw new TypeError(`Protocol ${url.protocol} is not supported`); } - const stats = options ?? (await stat(path)); + // Without waiting for a writer of a FIFO (`O_NONBLOCK` is not defined on + // Windows, which has no FIFOs) + const handle = await open(url, constants.O_RDONLY | constants.O_NONBLOCK); + // Closed by the stream of its content, or else here + let stream: Readable | null = null; + try { + const stats = await handle.stat(); - if (!stats.isFile()) { - throw new Error(`Provided path does not correspond to a regular file`); - } + if (!stats.isFile()) { + throw new Error("Provided path does not correspond to a regular file"); + } - const { mtime, size } = stats; - // In seconds, as an HTTP-date, and not in the future (RFC 9110, section 8.8.2.1) - const lm = new Date( - Math.floor(Math.min(mtime.getTime(), Date.now()) / 1000) * 1000, - ); + const { mtime, size } = stats; + // In seconds, as an HTTP-date, and not in the future (RFC 9110, section 8.8.2.1) + const lm = new Date( + Math.floor(Math.min(mtime.getTime(), Date.now()) / 1000) * 1000, + ); - this.set({ "Last-Modified": lm.toUTCString(), "Accept-Ranges": "bytes" }); + this.set({ "Last-Modified": lm.toUTCString(), "Accept-Ranges": "bytes" }); + + const { method = "GET", headers } = this.req; + const ims = + this.req instanceof BindenRequest + ? this.req.if_modified_since + : IfModifiedSince.fromString(headers["if-modified-since"]); + + // If-None-Match takes precedence over If-Modified-Since (RFC 9110, 13.1.3) + if ( + (method === "GET" || method === "HEAD") && + typeof headers["if-none-match"] === "undefined" && + ims && + lm <= ims.date + ) { + await this.status(304).send(); + return; + } else if (method === "HEAD") { + // The headers of the whole file, without reading it + await this.setHeader("Content-Length", size).send(); + return; + } - const { method = "GET", headers } = this.req; - const ims = - this.req instanceof BindenRequest - ? this.req.if_modified_since - : IfModifiedSince.fromString(headers["if-modified-since"]); + // Ranges apply to GET only (RFC 9110, section 14.2) + const ranges = + method !== "GET" || !this.#ifRange(lm) + ? [] + : this.req instanceof BindenRequest + ? this.req.range + : Range.fromString(headers.range); + const range = BindenResponse.#range(ranges, size); + + if (range === "unsatisfiable") { + const cr = new ContentRange({ size }).toString(); + await this.setHeader("Content-Range", cr).status(416).send(); + return; + } - // If-None-Match takes precedence over If-Modified-Since (RFC 9110, 13.1.3) - if ( - (method === "GET" || method === "HEAD") && - typeof headers["if-none-match"] === "undefined" && - ims && - lm <= ims.date - ) { - return this.status(304).send(); - } else if (method === "HEAD") { - // The headers of the whole file, without reading it - return this.setHeader("Content-Length", size).send(); - } + if (range === null && size === 0) { + await this.setHeader("Content-Length", 0).send(); + return; + } - // Ranges apply to GET only (RFC 9110, section 14.2) - const ranges = - method !== "GET" || !this.#ifRange(lm) - ? [] - : this.req instanceof BindenRequest - ? this.req.range - : Range.fromString(headers.range); - const range = BindenResponse.#range(ranges, size); - - if (range === null) { - const stream = createReadStream(url); - return this.setHeader("Content-Length", size).send(stream); - } else if (range === "unsatisfiable") { - const cr = new ContentRange({ size }).toString(); - return this.setHeader("Content-Range", cr).status(416).send(); + // No more than the size that is sent as its length + const { start, end } = range ?? { start: 0, end: size - 1 }; + stream = handle.createReadStream({ start, end }); + if (range === null) { + await this.setHeader("Content-Length", size).send(stream); + return; + } + const cr = new ContentRange({ size, start, end }).toString(); + await this.set({ "Content-Length": end - start + 1, "Content-Range": cr }) + .status(206) + .send(stream); + } finally { + if (stream === null) { + await handle.close(); + } } - - const { start, end } = range; - const stream = createReadStream(url, { start, end }); - const cr = new ContentRange({ size, start, end }).toString(); - return this.set({ "Content-Length": end - start + 1, "Content-Range": cr }) - .status(206) - .send(stream); } /** Whether `If-Range`, when sent, matches the file (RFC 9110, section 13.1.5) */ diff --git a/packages/binden/test/response.test.ts b/packages/binden/test/response.test.ts index a3a76d9b..750971cc 100644 --- a/packages/binden/test/response.test.ts +++ b/packages/binden/test/response.test.ts @@ -2,7 +2,19 @@ import { deepStrictEqual, ok, throws, rejects } from "node:assert"; import { randomUUID } from "node:crypto"; import { once } from "node:events"; -import { writeFile, rm, mkdir, mkdtemp, stat } from "node:fs/promises"; +import { + type FileHandle, + appendFile, + writeFile, + rm, + mkdir, + mkdtemp, + open, + rename, + stat as statOf, + utimes, +} from "node:fs/promises"; +import { execFileSync } from "node:child_process"; import { IncomingMessage, Server, createServer, get } from "node:http"; import { type AddressInfo, Socket } from "node:net"; import { tmpdir } from "node:os"; @@ -114,7 +126,6 @@ suite("BindenResponse", () => { let msg: Buffer; let server: Server; let file_stats: Stats; - let dir_stats: Stats; beforeEach(async () => { msg = Buffer.from(randomUUID()); @@ -131,8 +142,7 @@ suite("BindenResponse", () => { resolve(); }); }); - dir_stats = await stat(dirPath); - file_stats = await stat(filePath); + file_stats = await statOf(filePath); }); test("ServerResponse", async () => { @@ -383,7 +393,7 @@ suite("BindenResponse", () => { // A `sendFile()` that fails before sending anything does not start it const failed = create(); - await rejects(() => failed.sendFile(dirPath, dir_stats)); + await rejects(() => failed.sendFile(dirPath)); ok(!failed.started); }); @@ -980,7 +990,7 @@ suite("BindenResponse", () => { const serverPromise = new Promise((resolve, reject) => { server.once("request", (_request, response) => { response - .sendFile(filePath, file_stats) + .sendFile(filePath) .catch(reject) .finally(() => response.end(resolve)); }); @@ -997,7 +1007,7 @@ suite("BindenResponse", () => { const serverPromise = new Promise((resolve, reject) => { server.once("request", (_request, response) => { response - .sendFile(filePath, file_stats) + .sendFile(filePath) .then(() => { server.once("request", (_request2, response2) => { response2.sendFile(filePath).then(resolve).catch(reject); @@ -1030,7 +1040,7 @@ suite("BindenResponse", () => { const serverPromise = new Promise((resolve, reject) => { server.once("request", (_request, response) => { response - .sendFile(filePath, file_stats) + .sendFile(filePath) .then(() => { server.once("request", (_request2, response2) => { response2.sendFile(filePath).then(resolve).catch(reject); @@ -1065,7 +1075,7 @@ suite("BindenResponse", () => { // A stale length would leave the client waiting for its bytes response .setHeader("Content-Length", 99) - .sendFile(filePath, file_stats) + .sendFile(filePath) .catch(reject) .finally(() => response.end(resolve)); }); @@ -1089,7 +1099,7 @@ suite("BindenResponse", () => { const serverPromise = new Promise((resolve, reject) => { server.once("request", (_request, response) => { response - .sendFile(filePath, file_stats) + .sendFile(filePath) .catch(reject) .finally(() => response.end(resolve)); }); @@ -1111,7 +1121,7 @@ suite("BindenResponse", () => { const serverPromise = new Promise((resolve, reject) => { server.once("request", (_request, response) => { response - .sendFile(filePath, file_stats) + .sendFile(filePath) .catch(reject) .finally(() => response.end(resolve)); }); @@ -1134,7 +1144,7 @@ suite("BindenResponse", () => { const serverPromise = new Promise((resolve, reject) => { server.once("request", (_request, response) => { response - .sendFile(filePath, file_stats) + .sendFile(filePath) .catch(reject) .finally(() => response.end(resolve)); }); @@ -1319,14 +1329,10 @@ suite("BindenResponse", () => { test(".sendFile() (a modification date in the future)", async () => { const mtime = new Date(Date.now() + 3600 * 1000); + await utimes(filePath, mtime, mtime); const serverPromise = new Promise((resolve, reject) => { server.once("request", (_request, response) => { - const stats = { - isFile: (): boolean => true, - mtime, - size: msg.byteLength, - }; - response.sendFile(filePath, stats).then(resolve, reject); + response.sendFile(filePath).then(resolve, reject); }); }); @@ -1368,7 +1374,7 @@ suite("BindenResponse", () => { const serverPromise = new Promise((resolve, reject) => { server.once("request", (_request, response) => { response - .sendFile(filePath, file_stats) + .sendFile(filePath) .catch(reject) .finally(() => response.end(resolve)); }); @@ -1384,7 +1390,7 @@ suite("BindenResponse", () => { const serverPromise = new Promise((resolve, reject) => { server.once("request", (_request, response) => { rejects( - () => response.sendFile(path, file_stats), + () => response.sendFile(path), new TypeError(`Protocol ${path.protocol} is not supported`), ) .catch(reject) @@ -1395,11 +1401,74 @@ suite("BindenResponse", () => { await serverPromise; }); + test(".sendFile() (the file that it has measured)", async () => { + // Changes the file (or its path) once `sendFile()` has its metadata + const probe = await open(filePath); + const prototype = Object.getPrototypeOf(probe) as { + stat(this: FileHandle): Promise; + }; + await probe.close(); + // eslint-disable-next-line @typescript-eslint/unbound-method -- called on a handle below + const original = prototype.stat; + const check = async (change: () => Promise): Promise => { + prototype.stat = async function stat(this: FileHandle): Promise { + const stats = await original.call(this); + await change(); + return stats; + }; + try { + const sent = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + response.sendFile(filePath).then(resolve, reject); + }); + }); + const response = await fetch(url, { + signal: AbortSignal.timeout(5000), + }); + deepStrictEqual(await response.text(), msg.toString()); + await sent; + } finally { + prototype.stat = original; + } + }; + + // No more than its size, which is its `Content-Length` + await check(() => appendFile(filePath, "appended")); + await writeFile(filePath, msg); + // The content of the file it has opened + await check(async () => { + await rename(filePath, join(root, "moved")); + await writeFile(filePath, "another file"); + }); + }); + + test( + ".sendFile() (a FIFO)", + { skip: process.platform === "win32", timeout: 10_000 }, + async () => { + // Opened without waiting for a writer, which never comes + const fifo = join(root, "fifo"); + execFileSync("mkfifo", [fifo]); + const sent = new Promise((resolve, reject) => { + server.once("request", (_request, response) => { + rejects( + () => response.sendFile(fifo), + new Error("Provided path does not correspond to a regular file"), + ) + .catch(reject) + .finally(() => response.end(resolve)); + }); + }); + await (await fetch(url)).text(); + await sent; + }, + ); + test(".sendFile() (not a regular file)", async () => { const serverPromise = new Promise((resolve, reject) => { server.once("request", (_request, response) => { rejects( - () => response.sendFile(dirPath, dir_stats), + () => response.sendFile(dirPath), new Error(`Provided path does not correspond to a regular file`), ) .catch(reject) From 4a538a3f0705757899c8431eaa87096fd1ad86f0 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 03:51:37 +0300 Subject: [PATCH 088/675] fix: evaluate every precondition of sendFile in RFC 9110 order in binden `sendFile()` evaluated `If-Modified-Since` only, and skipped it whenever `If-None-Match` was sent, so that with an `ETag` set on the response, `If-Match` with another tag or an old `If-Unmodified-Since` still sent the file instead of 412, and `If-None-Match` with the tag sent the whole file again instead of 304. An `If-Range` date matched a `Last-Modified` of the same second as the response, which is not a strong validator. The preconditions are now evaluated in the order of RFC 9110 (section 13.2.2), with the `ETag` set on the response. `If-Match` (or else `If-Unmodified-Since`) gives 412 when it fails, by the strong comparison and with `*` for the file that exists. `If-None-Match` (or else `If-Modified-Since`, for `GET` and `HEAD`) gives 304 to `GET` and `HEAD`, and 412 to other methods, by the weak comparison. An `If-Range` date applies the ranges only when the `Last-Modified` is at least a second old (RFC 9110, section 8.8.2.2). Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/response.ts | 85 +++++++++++++++++++++++---- packages/binden/test/response.test.ts | 62 ++++++++++++++++++- 2 files changed, 135 insertions(+), 12 deletions(-) diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index 78224811..81b3b447 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -1,12 +1,17 @@ import { constants } from "node:fs"; import { open } from "node:fs/promises"; -import { IncomingMessage, ServerResponse } from "node:http"; +import { + type IncomingHttpHeaders, + IncomingMessage, + ServerResponse, +} from "node:http"; import { Readable } from "node:stream"; import { pathToFileURL } from "node:url"; import ContentRange from "./headers/content-range.js"; import Cookie from "./headers/cookie.js"; import IfModifiedSince from "./headers/if-modified-since.js"; import { parseHttpDate } from "./headers/http-date.js"; +import { split } from "./headers/list.js"; import Range from "./headers/range.js"; import { BindenRequest } from "./request.js"; import { isStatus } from "./status.js"; @@ -452,14 +457,15 @@ export class BindenResponse< ? this.req.if_modified_since : IfModifiedSince.fromString(headers["if-modified-since"]); - // If-None-Match takes precedence over If-Modified-Since (RFC 9110, 13.1.3) - if ( - (method === "GET" || method === "HEAD") && - typeof headers["if-none-match"] === "undefined" && - ims && - lm <= ims.date - ) { - await this.status(304).send(); + const failed = BindenResponse.#preconditions( + headers, + this.getHeader("ETag"), + lm, + method === "GET" || method === "HEAD", + ims, + ); + if (failed !== null) { + await this.status(failed).send(); return; } else if (method === "HEAD") { // The headers of the whole file, without reading it @@ -505,6 +511,58 @@ export class BindenResponse< } } + /** + * The status of a request whose precondition fails, in the order of RFC 9110 + * (section 13.2.2), or `null` when they pass: `If-Match` (or else + * `If-Unmodified-Since`), then `If-None-Match` (or else `If-Modified-Since`, + * for `GET` and `HEAD`), with the `ETag` set on the response + */ + static #preconditions( + headers: IncomingHttpHeaders, + etag: unknown, + lm: Date, + read: boolean, + ims: IfModifiedSince | null, + ): 304 | 412 | null { + const tag = typeof etag === "string" ? etag : null; + const ifMatch = headers["if-match"]; + const ius = parseHttpDate(headers["if-unmodified-since"]?.trim() ?? ""); + const ifNoneMatch = headers["if-none-match"]; + if (typeof ifMatch === "string") { + if (!BindenResponse.#matches(ifMatch, tag, true)) { + return 412; + } + } else if (ius !== null && lm > ius) { + return 412; + } + if (typeof ifNoneMatch === "string") { + if (BindenResponse.#matches(ifNoneMatch, tag, false)) { + return read ? 304 : 412; + } + } else if (read && ims !== null && lm <= ims.date) { + return 304; + } + return null; + } + + /** + * Whether a list of entity-tags (or `*`, as the file exists) matches the + * `ETag` of the response, by the strong or the weak comparison (RFC 9110, + * section 8.8.3.2) + */ + static #matches(list: string, etag: string | null, strong: boolean): boolean { + if (list.trim() === "*") { + return true; + } else if (etag === null || (strong && etag.startsWith("W/"))) { + return false; + } + const opaque = etag.replace(/^W\//u, ""); + return split(list, ",").some((value) => { + const tag = value.trim(); + return strong ? tag === etag : tag.replace(/^W\//u, "") === opaque; + }); + } + /** Whether `If-Range`, when sent, matches the file (RFC 9110, section 13.1.5) */ #ifRange(lm: Date): boolean { const raw = this.req.headers["if-range"]; @@ -517,8 +575,13 @@ export class BindenResponse< const etag = this.getHeader("ETag"); return etag === value && !value.startsWith("W/"); } - // An HTTP-date matches the `Last-Modified` exactly - return parseHttpDate(value)?.getTime() === lm.getTime(); + // An HTTP-date matches the `Last-Modified` exactly, when it is a strong + // validator: at least a second before now (RFC 9110, section 8.8.2.2) + const now = Math.floor(Date.now() / 1000) * 1000; + return ( + parseHttpDate(value)?.getTime() === lm.getTime() && + now - lm.getTime() >= 1000 + ); } /** diff --git a/packages/binden/test/response.test.ts b/packages/binden/test/response.test.ts index 750971cc..32b21dc1 100644 --- a/packages/binden/test/response.test.ts +++ b/packages/binden/test/response.test.ts @@ -1230,7 +1230,9 @@ suite("BindenResponse", () => { }; const whole = msg.toString(); const size = msg.byteLength; - const seconds = Math.floor(file_stats.mtime.getTime() / 1000) * 1000; + // Modified an hour ago: its `Last-Modified` is a strong validator + const seconds = Math.floor(Date.now() / 1000) * 1000 - 3600 * 1000; + await utimes(filePath, new Date(seconds), new Date(seconds)); const lm = new Date(seconds).toUTCString(); const range = { Range: "bytes=0-1" }; const partial = [206, `bytes 0-1/${size}`, whole.slice(0, 2)]; @@ -1290,12 +1292,70 @@ suite("BindenResponse", () => { [200, null, whole], ); + // A file modified within the last second has a weak `Last-Modified` + const recent = join(root, "recent"); + await writeFile(recent, msg); + const { mtime } = await statOf(recent); + const recentLm = new Date(Math.floor(mtime.getTime() / 1000) * 1000); + deepStrictEqual( + await send( + { ...range, "If-Range": recentLm.toUTCString() }, + { path: recent }, + ), + [200, null, whole], + ); + // If-None-Match takes precedence over If-Modified-Since deepStrictEqual((await send({ "If-Modified-Since": lm }))[0], 304); deepStrictEqual( await send({ "If-Modified-Since": lm, "If-None-Match": `"x"` }), [200, null, whole], ); + + // The preconditions in their order (RFC 9110, section 13.2.2) + const status = async ( + headers: Record, + { method = "GET", etag = `"tag"` } = {}, + ): Promise => (await send(headers, { method, etag }))[0]; + const old = new Date(seconds - 1000).toUTCString(); + const now = new Date().toUTCString(); + // If-Match: the strong comparison, `*` for a file that exists + deepStrictEqual(await status({ "If-Match": `"other"` }), 412); + deepStrictEqual(await status({ "If-Match": `"other", "tag"` }), 200); + deepStrictEqual(await status({ "If-Match": "*" }), 200); + deepStrictEqual(await status({ "If-Match": `W/"tag"` }), 412); + deepStrictEqual( + await status({ "If-Match": `W/"tag"` }, { etag: `W/"tag"` }), + 412, + ); + deepStrictEqual(await status({ "If-Match": `"tag"` }, { etag: "" }), 412); + // If-Unmodified-Since, unless If-Match is sent + deepStrictEqual(await status({ "If-Unmodified-Since": old }), 412); + deepStrictEqual(await status({ "If-Unmodified-Since": now }), 200); + deepStrictEqual(await status({ "If-Unmodified-Since": "garbage" }), 200); + deepStrictEqual( + await status({ "If-Match": `"tag"`, "If-Unmodified-Since": old }), + 200, + ); + // If-None-Match: the weak comparison, 412 for other methods + deepStrictEqual(await status({ "If-None-Match": `"tag"` }), 304); + deepStrictEqual(await status({ "If-None-Match": `W/"tag"` }), 304); + deepStrictEqual(await status({ "If-None-Match": `"a", "tag"` }), 304); + deepStrictEqual(await status({ "If-None-Match": "*" }), 304); + deepStrictEqual(await status({ "If-None-Match": `"other"` }), 200); + deepStrictEqual( + await status({ "If-None-Match": `"tag"` }, { method: "POST" }), + 412, + ); + deepStrictEqual( + await status({ "If-None-Match": `"tag"` }, { method: "HEAD" }), + 304, + ); + // If-Match passes, If-None-Match fails + deepStrictEqual( + await status({ "If-Match": `"tag"`, "If-None-Match": `"tag"` }), + 304, + ); }); test(".sendFile() (an empty file)", async () => { From 4ebd6cfa52b2ae0d6338ee7f71555c168f19c78f Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 03:53:02 +0300 Subject: [PATCH 089/675] fix: weaken the ETag of a compressed response in @binden/compression A compressed response kept the strong `ETag` set for the uncompressed body, although their bytes differ. The identity and the gzip representations of a response shared one strong validator, so that a client could resume a compressed download with a range of the uncompressed file (`If-Range` with that `ETag` gives a 206 of the uncompressed bytes). When the compression applies (also to `HEAD`), a strong `ETag` now becomes weak (RFC 9110, section 8.8.3.3), which keeps it for caching (`If-None-Match` compares weakly), but not for ranges and `If-Match`. Co-Authored-By: Claude Opus 5.5 --- packages/compression/index.ts | 6 ++++ packages/compression/test/compression.test.ts | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/packages/compression/index.ts b/packages/compression/index.ts index 62e0b1fc..93eee097 100644 --- a/packages/compression/index.ts +++ b/packages/compression/index.ts @@ -92,6 +92,12 @@ export class Compression extends Middleware { : [...(Array.isArray(raw) ? raw : [`${raw}`]), format], ); response.removeHeader("Content-Length"); + // A strong validator of the uncompressed body does not validate the + // compressed one (RFC 9110, section 8.8.3.3): it becomes weak + const etag = response.getHeader("ETag"); + if (typeof etag === "string" && !etag.startsWith("W/")) { + response.setHeader("ETag", `W/${etag}`); + } } if (active && !head) { // Backpressure in both directions: a compressed chunk is acknowledged diff --git a/packages/compression/test/compression.test.ts b/packages/compression/test/compression.test.ts index 5b4468b2..08c87b7e 100644 --- a/packages/compression/test/compression.test.ts +++ b/packages/compression/test/compression.test.ts @@ -485,6 +485,36 @@ describe("Compression", () => { }, ); + it("weakens the strong `ETag` of a compressed response", async () => { + const body = "Some text to compress. ".repeat(16); + app.use(new Compression({ format: "auto" }), (ct: Context) => { + const { pathname } = ct.url; + ct.setHeader( + "ETag", + pathname === "/weak" ? `W/"same-tag"` : `"same-tag"`, + ); + return ct.send(body); + }); + const etag = async ( + path: string, + encoding: string, + method = "GET", + ): Promise => { + const response = await request(new URL(path, url), { + method, + headers: { "Accept-Encoding": encoding }, + }); + await response.body.text(); + return response.headers.etag; + }; + + // The representations differ: they do not share a strong validator + deepStrictEqual(await etag("/", "identity"), `"same-tag"`); + deepStrictEqual(await etag("/", "gzip"), `W/"same-tag"`); + deepStrictEqual(await etag("/", "gzip", "HEAD"), `W/"same-tag"`); + deepStrictEqual(await etag("/weak", "gzip"), `W/"same-tag"`); + }); + it( "does not compress partial responses (`Range`)", { timeout: 10_000 }, From 207448fb76f78d894c55d6693df30cfb8d7c2e6b Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 03:55:34 +0300 Subject: [PATCH 090/675] fix: log only the path of a request-target in binden The request serializer logged the request-target without its query string only, which kept the userinfo of an absolute-form target (`http://alice:secretPW@trusted.test/x` was logged with the password) and its fragment. The error serializer walked the prototypes of a thrown value without a bound: a proxy that is its own prototype (or returns a new proxy every time) blocked the event loop as soon as it was logged. The request serializer now logs the path of the target only, without its query, its fragment, nor the scheme, the authority and the userinfo of an absolute-form target. The walk of the prototypes stops at a prototype that it has already seen, and after 64 of them. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/logger.ts | 18 +++++++++++-- .../binden/test/fixtures/cyclic-prototype.ts | 27 +++++++++++++++++++ packages/binden/test/logger.test.ts | 16 +++++++++++ packages/binden/test/process.test.ts | 8 ++++++ 4 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 packages/binden/test/fixtures/cyclic-prototype.ts diff --git a/packages/binden/src/logger.ts b/packages/binden/src/logger.ts index cfd0f4e8..74f549c5 100644 --- a/packages/binden/src/logger.ts +++ b/packages/binden/src/logger.ts @@ -42,8 +42,11 @@ const errorCodes: ReadonlySet = new Set([ /** The name of the nearest known error class of a value (never one it defines itself) */ function typeOf(value: object): string { try { + // Bounded: a proxy may return itself, or another proxy every time + const seen = new Set(); let prototype = Object.getPrototypeOf(value) as object | null; - while (prototype !== null) { + while (prototype !== null && !seen.has(prototype) && seen.size < 64) { + seen.add(prototype); const type = errorTypes.get(prototype); if (typeof type === "string") { return type; @@ -56,6 +59,17 @@ function typeOf(value: object): string { return typeof value; } +/** + * The path of a request-target: never its query or fragment, nor the scheme, + * the authority or the userinfo of an absolute-form target + */ +function pathOf(target: string): string { + if (target.startsWith("/")) { + return target.replace(/[?#].*$/su, ""); + } + return URL.canParse(target) ? new URL(target).pathname : ""; +} + /** The given headers, when they are set */ function pick( names: readonly string[], @@ -104,7 +118,7 @@ export const serializers = { } return { method: request.method, - url: (request.url ?? "").split("?", 1)[0], + url: pathOf(request.url ?? ""), headers: pick(requestHeaders, (name) => request.headers[name]), }; }, diff --git a/packages/binden/test/fixtures/cyclic-prototype.ts b/packages/binden/test/fixtures/cyclic-prototype.ts new file mode 100644 index 00000000..1b668e19 --- /dev/null +++ b/packages/binden/test/fixtures/cyclic-prototype.ts @@ -0,0 +1,27 @@ +// Run by `process.test.ts`: the error serializer must not loop forever on a +// value whose chain of prototypes never ends (it would block the event loop) +import { serializers } from "../../index.js"; + +// Its prototype is itself +const cyclic: object = new Proxy( + {}, + { + getPrototypeOf(): object { + return cyclic; + }, + }, +); +// Its prototype is a new proxy every time +const endless = (): object => + new Proxy( + {}, + { + getPrototypeOf(): object { + return endless(); + }, + }, + ); + +process.stdout.write( + `${JSON.stringify([serializers.error(cyclic), serializers.error(endless())])}\n`, +); diff --git a/packages/binden/test/logger.test.ts b/packages/binden/test/logger.test.ts index 6488ee4a..7542dd06 100644 --- a/packages/binden/test/logger.test.ts +++ b/packages/binden/test/logger.test.ts @@ -81,6 +81,22 @@ suite("serializers", () => { deepStrictEqual(serializers.request("not a request"), "not a request"); }); + test("request: the path of the request-target only", () => { + const url = (target: string): unknown => { + const request = new BindenRequest(new Socket()); + request.url = target; + return (serializers.request(request) as { url: unknown }).url; + }; + + deepStrictEqual(url("/x?token=hidden#fragment"), "/x"); + deepStrictEqual(url("/x#fragment"), "/x"); + // A path, not an authority + deepStrictEqual(url("//evil.test/x?token=hidden"), "//evil.test/x"); + // Not the userinfo, nor the authority of an absolute-form target + deepStrictEqual(url("http://alice:secretPW@trusted.test/x?t=1"), "/x"); + deepStrictEqual(url("*"), ""); + }); + test("response: an allowlist of details", () => { const { response } = createContext(); diff --git a/packages/binden/test/process.test.ts b/packages/binden/test/process.test.ts index 8c9d4724..655364c1 100644 --- a/packages/binden/test/process.test.ts +++ b/packages/binden/test/process.test.ts @@ -26,6 +26,14 @@ suite("Process safety", () => { }); }); + test("the error serializer ends on a chain of prototypes that does not", () => { + deepStrictEqual(run("cyclic-prototype.ts"), { + status: 0, + stdout: `[{"type":"object"},{"type":"object"}]\n`, + stderr: "", + }); + }); + test("a logger whose `child()` throws does not crash the process", () => { deepStrictEqual(run("throwing-logger.ts"), { status: 0, From ffd5998a0d3cc9a142a898d87898d7e80dbaecc1 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 03:57:09 +0300 Subject: [PATCH 091/675] fix: parse Content-Range strictly in binden `ContentRange.fromString()` split its input on spaces, `/` and `-`, read the positions with `Number()` and ignored the rest: `bytes //` threw a `TypeError`, hexadecimal or exponent positions (`bytes 0x0-0x9/0x10`, `bytes 0-9/1e2`) and extra parts (`bytes 0-9/100/extra`, `bytes 1-2-3/10`) were accepted, and so was a trailing CR/LF. A content range is now `bytes -/` (the size may be `*`) or an unsatisfied range of the size (RFC 9110, section 14.4), with decimal positions that are safe integers, a case-insensitive unit and only spaces or tabs around it. Anything else is not a content range, without throwing. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/headers/content-range.ts | 58 +++++++++---------- .../binden/test/headers/content-range.test.ts | 25 +++++++- 2 files changed, 50 insertions(+), 33 deletions(-) diff --git a/packages/binden/src/headers/content-range.ts b/packages/binden/src/headers/content-range.ts index 43772660..8a1b8b57 100644 --- a/packages/binden/src/headers/content-range.ts +++ b/packages/binden/src/headers/content-range.ts @@ -48,43 +48,37 @@ export class ContentRange extends Range implements IContentRange { : `${this.unit} */${this.size}`; } + /** + * A range (`bytes 0-9/100`, or with an unknown size `*`) or an unsatisfied + * range (an asterisk and the size) of RFC 9110 (section 14.4), with decimal + * positions only: anything else is not a content range + */ public static fromString(input?: string): [] | [ContentRange] { - if (typeof input === "undefined") { - return []; - } - - const trimmed = input.trim(); - - if (!trimmed.startsWith("bytes ")) { - return []; - } - - const [rawRange, rawSize] = trimmed - .substring(6) - .split("/") - .map((e) => e.trim()) - .filter((e) => e); - - if (rawRange === "*") { - try { - return [new ContentRange({ size: Number(rawSize) })]; - } catch { - return []; - } - } - - const [start, end] = rawRange - .split("-") - .map((e) => e.trim()) - .filter((e) => e) - .map((e) => Number(e)); + const { + first, + last, + size = "", + length, + } = /^[ \t]*bytes (?:(?\d+)-(?\d+)\/(?\d+|\*)|\*\/(?\d+))[ \t]*$/iu.exec( + input ?? "", + )?.groups ?? {}; try { - const size = rawSize === "*" ? rawSize : Number(rawSize); - return [new ContentRange({ start, end, size })]; + if (typeof length === "string") { + return [new ContentRange({ size: Number(length) })]; + } else if (typeof first === "string" && typeof last === "string") { + return [ + new ContentRange({ + start: Number(first), + end: Number(last), + size: size === "*" ? size : Number(size), + }), + ]; + } } catch { - return []; + // E.g. `bytes 1-0/5`, or a position that is not a safe integer } + return []; } } diff --git a/packages/binden/test/headers/content-range.test.ts b/packages/binden/test/headers/content-range.test.ts index e96b7923..ccf7af4f 100644 --- a/packages/binden/test/headers/content-range.test.ts +++ b/packages/binden/test/headers/content-range.test.ts @@ -91,10 +91,33 @@ suite("ContentRange", () => { deepStrictEqual(ContentRange.fromString("bytes */*"), []); deepStrictEqual(ContentRange.fromString("bytes 1-0/500"), []); deepStrictEqual(ContentRange.fromString("bytes 1-1000/500"), []); + // Decimal positions only, and nothing else (RFC 9110, section 14.4) + for (const input of [ + "bytes //", + "bytes /", + "bytes /", + "bytes 0x0-0x9/0x10", + "bytes 0-9/1e2", + "bytes 0-9/100/extra", + "bytes 1-2-3/10", + "bytes 0-9/100\r\n", + "bytes 0 - 9/100", + "bytes 200 - 1000 / 67589 / 1 __ignored__ 1", + "bytes 99999999999999999-99999999999999999/*", + "items 0-9/100", + ]) { + deepStrictEqual(ContentRange.fromString(input), [], input); + } + }); + + test(".fromString() (spaces and the case of the unit)", () => { + const [actual] = ContentRange.fromString(" BYTES 0-9/100\t"); + ok(actual); + deepStrictEqual(actual.toString(), "bytes 0-9/100"); }); test(".fromString()", () => { - const input = "bytes 200 - 1000 / 67589 / 1 __ignored__ 1"; + const input = "bytes 200-1000/67589"; const cr = new ContentRange({ start: 200, end: 1000, size: 67589 }); const parsed = ContentRange.fromString(input); const [actual] = parsed; From 213aa97a20f57f910028a02a47bad0950c11483f Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 04:02:02 +0300 Subject: [PATCH 092/675] fix!: validate the constructors of binden headers The constructors of the headers accepted what their parsers reject, and serialized it as is. `new Range({})` was `bytes=-`, a negative or reversed range was kept, and a position that is not an integer silently became `null`. `new AcceptEncoding()` took any coding and weight (`q=2`, `q=NaN`), and `new ContentType()` any type, and pasted `charset` unquoted, so that a charset such as `utf-8; boundary=attacker` added a parameter. `ContentType` kept only `charset` and `boundary`, and lost every other parameter. The constructors now throw a `TypeError` for invalid input, which is a range with a position that is not a non-negative integer, no position at all or a reversed one, an unknown coding or a weight that is not a qvalue, a type that is not `type/subtype` of tokens, or a parameter that has no token as name or has control characters in its value. `ContentType` keeps every parameter (the new `parameters`, by lowercase name) and quotes a value that is not a token. `ContentType.fromString()` returns `null` for a type that is not a media type. BREAKING CHANGE: the constructors of `Range`, `AcceptEncoding` and `ContentType` throw a `TypeError` for invalid input, and `ContentType#toString()` includes every parameter. Co-Authored-By: Claude Opus 5.5 --- .../binden/src/headers/accept-encoding.ts | 32 +++--- packages/binden/src/headers/content-type.ts | 101 ++++++++++++++---- packages/binden/src/headers/range.ts | 24 ++++- .../test/headers/accept-encoding.test.ts | 30 +++++- .../binden/test/headers/content-type.test.ts | 55 ++++++++++ packages/binden/test/headers/range.test.ts | 34 +++--- 6 files changed, 224 insertions(+), 52 deletions(-) diff --git a/packages/binden/src/headers/accept-encoding.ts b/packages/binden/src/headers/accept-encoding.ts index dc5d563a..a86da105 100644 --- a/packages/binden/src/headers/accept-encoding.ts +++ b/packages/binden/src/headers/accept-encoding.ts @@ -6,11 +6,19 @@ export interface IAcceptEncoding { q_value?: number | null; } +/** A weight from 0 to 1, with at most 3 decimals (RFC 9110, section 12.4.2) */ +const qvalue = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/u; + export class AcceptEncoding implements IAcceptEncoding { readonly #encoding: IAcceptEncodings; readonly #q_value: number | null; public constructor({ encoding, q_value = null }: IAcceptEncoding) { + if (!AcceptEncoding.#encodings.includes(encoding)) { + throw new TypeError(`Unknown content coding: ${String(encoding)}`); + } else if (q_value !== null && !qvalue.test(`${q_value}`)) { + throw new TypeError(`\`q_value\` is not a qvalue: ${q_value}`); + } this.#encoding = encoding; this.#q_value = q_value; } @@ -37,16 +45,6 @@ export class AcceptEncoding implements IAcceptEncoding { input = input.join(","); } - const encodings = [ - "gzip", - "x-gzip", - "compress", - "deflate", - "br", - "*", - "identity", - ]; - // Content codings and the `q` parameter are case-insensitive (RFC 9110) return input .split(",") @@ -62,17 +60,27 @@ export class AcceptEncoding implements IAcceptEncoding { } // A weight is a qvalue (RFC 9110, section 12.4.2), or the element is invalid const value = q.trim(); - return /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/u.test(value) + return qvalue.test(value) ? [coding.toLowerCase(), Number(value)] : null; }) .filter( (e): e is [IAcceptEncodings, number | null] => - e !== null && encodings.includes(e[0]), + e !== null && AcceptEncoding.#encodings.includes(e[0]), ) .map(([encoding, q_value]) => new AcceptEncoding({ encoding, q_value })) .sort(({ q_value: a }, { q_value: b }) => (b ?? 1) - (a ?? 1)); } + + static readonly #encodings: readonly string[] = [ + "gzip", + "x-gzip", + "compress", + "deflate", + "br", + "*", + "identity", + ]; } export default AcceptEncoding; diff --git a/packages/binden/src/headers/content-type.ts b/packages/binden/src/headers/content-type.ts index 0f7b2a27..47d00433 100644 --- a/packages/binden/src/headers/content-type.ts +++ b/packages/binden/src/headers/content-type.ts @@ -4,25 +4,78 @@ export interface IContentType { type: string; charset?: string | null; boundary?: string | null; + /** Other parameters (their names are case-insensitive) */ + parameters?: Readonly>; } +const token = "[!#$%&'*+.^_`|~\\w-]+"; + +/** `type/subtype` */ +const mediaType = new RegExp(`^${token}/${token}$`, "u"); + +/** The name of a parameter */ +const parameterName = new RegExp(`^${token}$`, "u"); + +/** A value without control characters (but tabs), which a quoted string cannot have */ +// eslint-disable-next-line no-control-regex -- control characters on purpose +const parameterValue = /^[^\u0000-\u0008\u000A-\u001F\u007F]*$/u; + export class ContentType implements IContentType { readonly #type: string; readonly #charset: string | null; readonly #boundary: string | null; + readonly #parameters: ReadonlyMap; - public constructor({ type, charset = null, boundary = null }: IContentType) { + /** + * A media type (`type/subtype`, of tokens) with its parameters: a token as + * name, and a value without control characters (sent as a quoted string + * when it is not a token). Invalid ones throw a `TypeError`. + */ + public constructor({ + type, + charset = null, + boundary = null, + parameters = {}, + }: IContentType) { + if (!mediaType.test(type)) { + throw new TypeError(`Invalid media type: ${type}`); + } + const all = new Map(); + for (const [name, value] of Object.entries(parameters)) { + const key = name.toLowerCase(); + if (!all.has(key)) { + all.set(key, value); + } + } this.#type = type; this.#boundary = null; this.#charset = null; + // `boundary` (of `multipart/form-data` only) and `charset` (of the other + // types) are the options themselves if (type === "multipart/form-data") { if (typeof boundary !== "string" || !boundary) { throw new TypeError("`boundary` is missing"); } this.#boundary = boundary; - } else if (typeof charset === "string" && charset) { - this.#charset = charset; + all.set("boundary", boundary); + all.delete("charset"); + } else { + all.delete("boundary"); + if (typeof charset === "string" && charset) { + this.#charset = charset; + all.set("charset", charset); + } else { + all.delete("charset"); + } + } + for (const [name, value] of all) { + if (!parameterName.test(name)) { + throw new TypeError(`Invalid parameter name: ${name}`); + } else if (!parameterValue.test(value)) { + throw new TypeError(`Invalid value of the parameter ${name}`); + } } + this.#parameters = all; } public get type(): string { @@ -37,12 +90,18 @@ export class ContentType implements IContentType { return this.#boundary; } + /** Every parameter, by its name in lowercase */ + public get parameters(): Record { + return Object.fromEntries(this.#parameters); + } + public toString(): string { - return typeof this.boundary === "string" - ? `${this.type}; boundary=${ContentType.#quote(this.boundary)}` - : typeof this.charset === "string" && this.charset - ? `${this.type}; charset=${this.charset}` - : this.type; + return [ + this.type, + ...[...this.#parameters].map( + ([name, value]) => `${name}=${ContentType.#quote(value)}`, + ), + ].join("; "); } /** `type/subtype *( OWS ";" OWS parameter )` (RFC 9110, section 8.3.1) */ @@ -50,28 +109,28 @@ export class ContentType implements IContentType { const [rawType = "", ...rest] = split(input ?? "", ";"); const type = rawType.trim().toLowerCase(); - if (!type) { - return null; - } - // Parameter names are case-insensitive (RFC 9110, section 5.6.6) const parameters = new Map(); for (const parameter of rest) { const index = parameter.indexOf("="); const name = parameter.slice(0, Math.max(index, 0)).trim().toLowerCase(); - if (index > 0 && !parameters.has(name)) { - const value = parameter.slice(index + 1).trim(); - parameters.set(name, unquote(value)); + if (parameterName.test(name) && !parameters.has(name)) { + const value = unquote(parameter.slice(index + 1).trim()); + parameters.set(name, name === "charset" ? value.toLowerCase() : value); } } - if (type === "multipart/form-data") { - const boundary = parameters.get("boundary") ?? ""; - return boundary ? new ContentType({ type, boundary }) : null; + try { + return new ContentType({ + type, + charset: parameters.get("charset") ?? null, + boundary: parameters.get("boundary") ?? null, + parameters: Object.fromEntries(parameters), + }); + } catch { + // E.g. not a media type, or `multipart/form-data` without a boundary + return null; } - - const charset = parameters.get("charset")?.toLowerCase() ?? null; - return new ContentType({ type, charset }); } /** A token as is, anything else as a quoted string */ diff --git a/packages/binden/src/headers/range.ts b/packages/binden/src/headers/range.ts index 23dff1f9..a00bc0b1 100644 --- a/packages/binden/src/headers/range.ts +++ b/packages/binden/src/headers/range.ts @@ -10,12 +10,28 @@ export class Range implements IRange { readonly #end: number | null; readonly #unit: IUnit; + /** + * A range of `start` to `end` (non-negative integers), from `start` to the + * end (without `end`), or of the last `end` bytes (without `start`) + */ public constructor({ start = null, end = null }: IRange) { + for (const [name, value] of [ + ["start", start], + ["end", end], + ] as const) { + if (value !== null && !(Number.isSafeInteger(value) && value >= 0)) { + throw new TypeError(`\`${name}\` is not a non-negative integer`); + } + } + // Only an unsatisfied `ContentRange` has neither + if (new.target === Range && start === null && end === null) { + throw new TypeError("A range has a `start` or an `end`"); + } else if (start !== null && end !== null && end < start) { + throw new TypeError("`end` is less than `start`"); + } this.#unit = "bytes"; - this.#start = - typeof start === "number" && Number.isSafeInteger(start) ? start : null; - this.#end = - typeof end === "number" && Number.isSafeInteger(end) ? end : null; + this.#start = start; + this.#end = end; } public get unit(): IUnit { diff --git a/packages/binden/test/headers/accept-encoding.test.ts b/packages/binden/test/headers/accept-encoding.test.ts index dde7e6f5..5c9d4078 100644 --- a/packages/binden/test/headers/accept-encoding.test.ts +++ b/packages/binden/test/headers/accept-encoding.test.ts @@ -1,7 +1,7 @@ -import { deepStrictEqual } from "node:assert"; +import { deepStrictEqual, throws } from "node:assert"; import { suite, test } from "node:test"; -import { AcceptEncoding } from "../../index.js"; +import { AcceptEncoding, type IAcceptEncodings } from "../../index.js"; suite("AcceptEncoding", () => { test("constructor", () => { @@ -98,4 +98,30 @@ suite("AcceptEncoding", () => { deepStrictEqual(parse(`gzip;q=${q}`), [], q); } }); + + test("constructor (invalid input)", () => { + for (const q_value of [ + 2, + -0.5, + 0.0001, + Number.NaN, + Number.POSITIVE_INFINITY, + ]) { + throws( + () => new AcceptEncoding({ encoding: "gzip", q_value }), + new TypeError(`\`q_value\` is not a qvalue: ${q_value}`), + ); + } + throws( + () => + new AcceptEncoding({ + encoding: "gzip, br;q=1" as IAcceptEncodings, + }), + new TypeError("Unknown content coding: gzip, br;q=1"), + ); + deepStrictEqual( + new AcceptEncoding({ encoding: "br", q_value: 0.5 }).toString(), + "br;q=0.5", + ); + }); }); diff --git a/packages/binden/test/headers/content-type.test.ts b/packages/binden/test/headers/content-type.test.ts index a88bd56f..05522ebb 100644 --- a/packages/binden/test/headers/content-type.test.ts +++ b/packages/binden/test/headers/content-type.test.ts @@ -159,4 +159,59 @@ suite("ContentType", () => { `${type}; boundary=token`, ); }); + + test("constructor (invalid input)", () => { + for (const type of ["text", "text/plain; x=y", "te xt/plain", ""]) { + throws( + () => new ContentType({ type }), + new TypeError(`Invalid media type: ${type}`), + ); + } + throws( + () => new ContentType({ type: "text/plain", parameters: { "a b": "c" } }), + new TypeError("Invalid parameter name: a b"), + ); + throws( + () => new ContentType({ type: "text/plain", charset: "utf-8\r\nX: y" }), + new TypeError("Invalid value of the parameter charset"), + ); + }); + + test("parameters (quoted when they are not tokens)", () => { + // The value of `charset` cannot add a parameter + const injected = new ContentType({ + type: "text/plain", + charset: "utf-8; boundary=attacker", + }); + deepStrictEqual( + injected.toString(), + `text/plain; charset="utf-8; boundary=attacker"`, + ); + deepStrictEqual(ContentType.fromString(injected.toString())?.parameters, { + charset: "utf-8; boundary=attacker", + }); + + // Every parameter is kept, by its name in lowercase + const parsed = ContentType.fromString( + "text/plain; Format=flowed; charset=UTF-8; delsp=yes; a b=c", + ); + deepStrictEqual(parsed?.parameters, { + format: "flowed", + charset: "utf-8", + delsp: "yes", + }); + deepStrictEqual( + parsed.toString(), + "text/plain; format=flowed; charset=utf-8; delsp=yes", + ); + deepStrictEqual( + new ContentType({ + type: "text/plain", + parameters: { Format: "flowed", format: "fixed" }, + }).toString(), + "text/plain; format=flowed", + ); + // Not a media type + deepStrictEqual(ContentType.fromString("text"), null); + }); }); diff --git a/packages/binden/test/headers/range.test.ts b/packages/binden/test/headers/range.test.ts index 7d93c98d..9c2968aa 100644 --- a/packages/binden/test/headers/range.test.ts +++ b/packages/binden/test/headers/range.test.ts @@ -1,4 +1,4 @@ -import { deepStrictEqual } from "node:assert"; +import { deepStrictEqual, throws } from "node:assert"; import { suite, test } from "node:test"; import { Range } from "../../index.js"; @@ -15,23 +15,31 @@ suite("Range", () => { }); test("constructor (no `start`)", () => { - const start = 0.1; - const end = 500; - const range = new Range({ start, end }); - deepStrictEqual(range.end, end); + const range = new Range({ end: 500 }); deepStrictEqual(range.start, null); - deepStrictEqual(range.unit, "bytes"); - deepStrictEqual(range.toString(), `bytes=-${end}`); + deepStrictEqual(range.end, 500); + deepStrictEqual(range.toString(), "bytes=-500"); }); test("constructor (no `end`)", () => { - const end = 499.9; - const start = 0; - const range = new Range({ start, end }); - deepStrictEqual(range.start, start); + const range = new Range({ start: 0 }); + deepStrictEqual(range.start, 0); deepStrictEqual(range.end, null); - deepStrictEqual(range.unit, "bytes"); - deepStrictEqual(range.toString(), `bytes=${start}-`); + deepStrictEqual(range.toString(), "bytes=0-"); + }); + + test("constructor (invalid ranges)", () => { + for (const [range, message] of [ + [{ start: 0.1, end: 500 }, "`start` is not a non-negative integer"], + [{ start: -1 }, "`start` is not a non-negative integer"], + [{ start: 0, end: 499.9 }, "`end` is not a non-negative integer"], + [{ end: Number.NaN }, "`end` is not a non-negative integer"], + [{ start: 2 ** 53 }, "`start` is not a non-negative integer"], + [{}, "A range has a `start` or an `end`"], + [{ start: 5, end: 1 }, "`end` is less than `start`"], + ] as const) { + throws(() => new Range(range), new TypeError(message)); + } }); test(".fromString()", () => { From e7aedd20755d4a3d4bafc8c3631ee79ec28534a7 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 04:03:48 +0300 Subject: [PATCH 093/675] fix: keep request cookies with an empty value in binden `Cookie.fromString()` (so `request.cookies`) skipped a cookie with an empty value, and any input shorter than three characters: `a=; b=ok` gave `b` only, although an empty value is a value (RFC 6265, section 4.1.1), which `new Cookie()` accepts. Such a cookie is now kept, with `""` as its value. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/headers/cookie.ts | 5 +++-- packages/binden/test/headers/cookie.test.ts | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/binden/src/headers/cookie.ts b/packages/binden/src/headers/cookie.ts index 3855ea06..b5b986c0 100644 --- a/packages/binden/src/headers/cookie.ts +++ b/packages/binden/src/headers/cookie.ts @@ -134,7 +134,7 @@ export class Cookie implements ICookie { } public static fromString(input?: string): Cookie[] { - if (typeof input !== "string" || input.length < 3) { + if (typeof input !== "string") { return []; } @@ -150,7 +150,8 @@ export class Cookie implements ICookie { ? raw.slice(1, -1) : raw; - if (index > 0 && value && !output.some((c) => c.key === key)) { + // An empty value is a value (RFC 6265, section 4.1.1) + if (index > 0 && !output.some((c) => c.key === key)) { try { output.push(new Cookie({ key, value })); } catch { diff --git a/packages/binden/test/headers/cookie.test.ts b/packages/binden/test/headers/cookie.test.ts index 276db7e0..8f909045 100644 --- a/packages/binden/test/headers/cookie.test.ts +++ b/packages/binden/test/headers/cookie.test.ts @@ -126,7 +126,16 @@ suite("Cookie", () => { test(".fromString()", () => { deepStrictEqual(Cookie.fromString(), []); - deepStrictEqual(Cookie.fromString("a="), []); + deepStrictEqual(Cookie.fromString(""), []); + deepStrictEqual(Cookie.fromString("a=")[0]?.value, ""); + // An empty value is a value + deepStrictEqual( + Cookie.fromString("a=; b=ok").map(({ key, value }) => [key, value]), + [ + ["a", ""], + ["b", "ok"], + ], + ); const input = ` Key1 = Value1 ; Key2 = "Value2" ; Key3 = " ; Key4=""`; const one = new Cookie({ key: "Key1", value: "Value1" }); @@ -134,7 +143,11 @@ suite("Cookie", () => { const parsed = Cookie.fromString(input); - deepStrictEqual(parsed.length, 2); + // `Key3` has an invalid value, `Key4` an empty one + deepStrictEqual( + parsed.map(({ key }) => key), + ["Key1", "Key2", "Key4"], + ); const [cookie1, cookie2] = parsed; @@ -217,6 +230,7 @@ suite("Cookie", () => { ["b", "2"], ["token", "abc=="], ["quoted", "q"], + ["empty", ""], ], ); }); From cecba08915d64faf1c48362bdd766743129a30b9 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 04:06:54 +0300 Subject: [PATCH 094/675] feat: limit the decoded body size in @binden/body `BodyParser` reads the whole body into memory, without any limit: a gzip body of 300 KB that decodes to 300 MB was accepted, and took as much memory. The new `limit` option sets the largest body in bytes, once decoded. A larger body gets a `413 Content Too Large`: it is not read (nor decoded) further, and the connection is closed after the reply. There is no limit by default; the README recommends one. Co-Authored-By: Claude Opus 5.5 --- packages/body/README.md | 13 ++++++++++ packages/body/index.ts | 37 ++++++++++++++++++++++++--- packages/body/test/index.test.ts | 44 +++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/packages/body/README.md b/packages/body/README.md index 13403b8c..70094125 100644 --- a/packages/body/README.md +++ b/packages/body/README.md @@ -35,6 +35,19 @@ class MyMiddleware extends Middleware { app.use("/deposit", new Router().post(BodyParser, new MyMiddleware())); ``` +### Body size + +The body is read into memory, and nothing limits its size by default: a small compressed body +may decode to gigabytes. Set `limit` (in bytes, of the decoded body) to reply +`413 Content Too Large` to a larger body, which is then not read further (the connection is +closed after the reply), for example 1 MiB: + +```typescript +import { BodyParser } from "@binden/body"; + +const body_parser = new BodyParser({ limit: 1024 * 1024 }); +``` + ### Custom `parse` function ```typescript diff --git a/packages/body/index.ts b/packages/body/index.ts index e60a8417..6ab4ad9b 100644 --- a/packages/body/index.ts +++ b/packages/body/index.ts @@ -18,17 +18,29 @@ export type IParse = (input: string) => T | undefined; export interface IBodyParserOptions extends IMiddlewareParams { parse?: IParse; + /** + * The largest body in bytes, once decoded (no limit by default): a larger + * one is not read further, and gets a `413 Content Too Large` + */ + limit?: number; } export class BodyParser extends Middleware { readonly #parse: IParse; + readonly #limit: number | null; - public constructor({ parse, ...rest }: IBodyParserOptions = {}) { + public constructor({ parse, limit, ...rest }: IBodyParserOptions = {}) { super(rest); if (typeof parse !== "undefined" && typeof parse !== "function") { throw new TypeError("`parse` is not a function"); + } else if ( + typeof limit !== "undefined" && + !(Number.isSafeInteger(limit) && limit >= 0) + ) { + throw new TypeError("`limit` is not a non-negative integer"); } this.#parse = parse ?? JSON.parse; + this.#limit = limit ?? null; } public async run(context: Context): Promise { @@ -101,8 +113,27 @@ export class BodyParser extends Middleware { }); const chunks: Buffer[] = []; - - stream.on("data", (data: Buffer) => chunks.push(data)); + let size = 0; + const limit = this.#limit; + + stream.on("data", (data: Buffer) => { + size += data.byteLength; + if (limit === null || size <= limit) { + chunks.push(data); + return; + } + // Not read further (e.g. a small compressed body that would decode to + // gigabytes): the connection is closed after the reply + log.debug("The body is too large", { limit }); + source.unpipe(); + source.pause(); + stream.removeAllListeners("data"); + for (const [, decompresser] of streams) { + decompresser.destroy(); + } + context.setHeader("Connection", "close"); + reject(new BindenError(413)); + }); finished(stream, (error) => { if (error) { diff --git a/packages/body/test/index.test.ts b/packages/body/test/index.test.ts index 03c568f7..fe252e21 100644 --- a/packages/body/test/index.test.ts +++ b/packages/body/test/index.test.ts @@ -11,7 +11,7 @@ import { type InputType, } from "node:zlib"; import { Ajv as AJV, type JTDDataType } from "ajv/dist/jtd.js"; -import { errors, request } from "undici"; +import { Agent, errors, request } from "undici"; import { Binden, Context, ct_text, ct_json, ct_form } from "binden"; import { BodyParser, type IParse } from "../index.js"; @@ -73,6 +73,48 @@ suite("BodyParser", () => { ); }); + test("constructor (with an invalid `limit` option)", () => { + for (const limit of [-1, 1.5, Number.NaN, "1" as unknown as number]) { + throws( + () => new BodyParser({ limit }), + new TypeError("`limit` is not a non-negative integer"), + ); + } + }); + + test("`limit` (the size of the decoded body)", async () => { + const received: unknown[] = []; + app.use(new BodyParser({ limit: 1024 }), (ct: Context) => { + received.push(ct.request.body); + return ct.send(); + }); + // Keeps its connections (unlike the global dispatcher of the tests) + const dispatcher = new Agent(); + const send = async ( + body: Buffer | string, + headers: Record = {}, + ): Promise<[number, unknown]> => { + const response = await request(url, { + method: "POST", + body, + dispatcher, + headers: { "Content-Type": ct_text, ...headers }, + }); + await response.body.text(); + return [response.statusCode, response.headers.connection]; + }; + + deepEqual(await send("a".repeat(1024)), [200, "keep-alive"]); + // Not read further: the connection is closed after the reply + deepEqual(await send("a".repeat(1025)), [413, "close"]); + // A small compressed body that decodes to more than the limit + const bomb = gzipSync(Buffer.alloc(64 * 1024)); + ok(bomb.byteLength < 1024); + deepEqual(await send(bomb, { "Content-Encoding": "gzip" }), [413, "close"]); + deepEqual(received, ["a".repeat(1024)]); + await dispatcher.close(); + }); + test("text", async () => { const body = "Hello World"; const assert = (ct: Context): Promise => { From 8605c0fbb7ad094e3c9f8a7a5fa7c9b44f7f0912 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 04:08:34 +0300 Subject: [PATCH 095/675] feat: limit the total size of a multipart request in @binden/multipart `Multipart` reads every field and file of a request into memory, and `busboy` limits only the size of a field by default: a request with a 200 MiB file and 2000 other files was accepted, and held in memory. The new `limit` option sets the largest body in bytes, all of its parts included. A larger body gets a `413 Content Too Large`: it is not read further, and the connection is closed after the reply. There is no limit by default; the README recommends one, with the `busboy` limits of the files. Co-Authored-By: Claude Opus 5.5 --- packages/multipart/README.md | 17 ++++++++ packages/multipart/index.ts | 39 ++++++++++++++++- packages/multipart/test/multipart.test.ts | 53 ++++++++++++++++++++++- 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/packages/multipart/README.md b/packages/multipart/README.md index 169fed4c..51928776 100644 --- a/packages/multipart/README.md +++ b/packages/multipart/README.md @@ -29,6 +29,23 @@ const next = (context: Context): Promise => { app.use("/formdata", new Router().post(formDataParser, next)); ``` +### Limits + +Fields and files are read into memory, and nothing limits them by default (`busboy` limits only +the size of a field, to 1 MiB). Set the `busboy` [limits](https://github.com/mscdex/busboy/#api) +of the files (`fileSize`, `files`, `parts`), and `limit`, the largest body in bytes (all of its +parts included): a larger one gets `413 Content Too Large`, and is not read further (the +connection is closed after the reply). + +```typescript +import { Multipart } from "@binden/multipart"; + +const formDataParser = new Multipart({ + limit: 20 * 1024 * 1024, + config: { limits: { fileSize: 10 * 1024 * 1024, files: 10, parts: 1000 } }, +}); +``` + ### Truncated `FormData` Set the `throw_limits` to `false` to avoid throwing errors. `FormData` might be truncated due to `config.limits` (See `busboy` [limits](https://github.com/mscdex/busboy/#api)) diff --git a/packages/multipart/index.ts b/packages/multipart/index.ts index 4c071faa..dd77402a 100644 --- a/packages/multipart/index.ts +++ b/packages/multipart/index.ts @@ -12,20 +12,35 @@ export type IMultipartConfig = Omit; export interface IMultipartOptions extends IMiddlewareParams { config?: IMultipartConfig; throw_limits?: boolean; + /** + * The largest body in bytes, all of its parts included (no limit by + * default): a larger one is not read further, and gets a + * `413 Content Too Large` + */ + limit?: number; } export class Multipart extends Middleware { readonly #config: IMultipartConfig; readonly #throw_limits: boolean; + readonly #limit: number | null; public constructor({ config = {}, throw_limits = true, + limit, ...rest }: IMultipartOptions = {}) { super(rest); + if ( + typeof limit !== "undefined" && + !(Number.isSafeInteger(limit) && limit >= 0) + ) { + throw new TypeError("`limit` is not a non-negative integer"); + } this.#config = config; this.#throw_limits = throw_limits; + this.#limit = limit ?? null; } public async run(context: Context): Promise { @@ -56,14 +71,19 @@ export class Multipart extends Middleware { await new Promise((resolve, reject) => { const fd = new FormData(); + let size = 0; + let tooLarge = false; + const limit = this.#limit; const bb = busboy({ headers, ...this.#config }) .once("error", (cause) => { const message = "`busboy` internal error"; reject(new BindenError(415, { message, cause })); }) .once("close", () => { - request.body = fd; - resolve(); + if (!tooLarge) { + request.body = fd; + resolve(); + } }) .on("field", (name, value, { valueTruncated }) => { // `nameTruncated` is never set for `multipart/form-data` (busboy applies @@ -129,6 +149,21 @@ export class Multipart extends Middleware { bb.destroy(error); } }); + if (limit !== null) { + request.on("data", (chunk: Buffer) => { + size += chunk.byteLength; + if (size > limit && !tooLarge) { + // Not read further: the connection is closed after the reply + tooLarge = true; + log.debug("The body is too large", { limit }); + request.unpipe(bb); + request.pause(); + bb.destroy(); + context.setHeader("Connection", "close"); + reject(new BindenError(413)); + } + }); + } request.pipe(bb); }); } diff --git a/packages/multipart/test/multipart.test.ts b/packages/multipart/test/multipart.test.ts index 4f8e7d21..0d2e2cae 100644 --- a/packages/multipart/test/multipart.test.ts +++ b/packages/multipart/test/multipart.test.ts @@ -1,6 +1,6 @@ /* eslint-disable init-declarations, sort-imports */ -import { deepEqual, ok } from "node:assert/strict"; -import { request, Server } from "node:http"; +import { deepEqual, ok, throws } from "node:assert/strict"; +import { Agent, request, Server } from "node:http"; import type { AddressInfo } from "node:net"; import { afterEach, beforeEach, describe, it } from "node:test"; import { Binden, Context } from "binden"; @@ -22,6 +22,55 @@ describe("Multipart", () => { }); }); + it("`limit` (the size of the body, all parts included)", async () => { + const received: unknown[] = []; + app.use(new Multipart({ limit: 1024 }), async (ct: Context) => { + received.push((ct.request.body as FormData).get("field")); + await ct.send(); + }); + // Keeps its connections (unlike the global agent of the tests) + const agent = new Agent({ keepAlive: true }); + const send = async (form: FormData): Promise => { + const encoded = new Request(url, { method: "POST", body: form }); + const body = Buffer.from(await encoded.arrayBuffer()); + const type = encoded.headers.get("Content-Type") ?? ""; + return new Promise((resolve, reject) => { + request(url, { + agent, + method: "POST", + headers: { "Content-Type": type }, + }) + .on("response", (response) => { + response.resume().once("end", () => { + resolve([response.statusCode, response.headers.connection]); + }); + }) + .on("error", reject) + .end(body); + }); + }; + const small = new FormData(); + small.append("field", "value"); + const large = new FormData(); + large.append("field", "value"); + large.append("file", new File([new Uint8Array(1024)], "file.bin")); + + deepEqual(await send(small), [200, "keep-alive"]); + // Not read further: the connection is closed after the reply + deepEqual(await send(large), [413, "close"]); + deepEqual(received, ["value"]); + agent.destroy(); + }); + + it("`limit` (invalid)", () => { + for (const limit of [-1, 0.5, Number.NaN]) { + throws( + () => new Multipart({ limit }), + new TypeError("`limit` is not a non-negative integer"), + ); + } + }); + it("Skips when method is `GET`", async () => { const message = { success: true }; const assert = async (ct: Context): Promise => { From a68448533c328a6cc1ecd4bca9c9afac8ca3918a Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 04:09:57 +0300 Subject: [PATCH 096/675] fix: decode bodies with their charset in @binden/body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BodyParser` decoded every body as UTF-8, whatever its `charset`: a `text/plain` body with `charset=iso-8859-1` was read with replacement characters (`café` became `caf�`). A body is now decoded with the charset of its `Content-Type` (UTF-8 by default, without a byte order mark), and a charset that cannot be decoded gets a `415 Unsupported Media Type`, before the body is read. Co-Authored-By: Claude Opus 5.5 --- packages/body/index.ts | 13 ++++++++++++- packages/body/test/index.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/body/index.ts b/packages/body/index.ts index 6ab4ad9b..a59ebdbf 100644 --- a/packages/body/index.ts +++ b/packages/body/index.ts @@ -74,6 +74,17 @@ export class BodyParser extends Middleware { throw new BindenError(415); } + // The text is decoded with its charset (UTF-8 by default) + const charset = content_type?.charset ?? "utf-8"; + const decoder = ((): TextDecoder => { + try { + return new TextDecoder(charset); + } catch (error: unknown) { + log.debug("Unsupported charset", { charset }); + throw new BindenError(415, { cause: error as Error }); + } + })(); + const streams: [string, Duplex][] = []; for (const { encoding } of content_encoding) { @@ -139,7 +150,7 @@ export class BodyParser extends Middleware { if (error) { reject(error); } else { - resolve(Buffer.concat(chunks).toString()); + resolve(decoder.decode(Buffer.concat(chunks))); } }); }); diff --git a/packages/body/test/index.test.ts b/packages/body/test/index.test.ts index fe252e21..8fd1b09f 100644 --- a/packages/body/test/index.test.ts +++ b/packages/body/test/index.test.ts @@ -131,6 +131,29 @@ suite("BodyParser", () => { deepEqual(text, ""); }); + test("charset", async () => { + const received: unknown[] = []; + app.use(new BodyParser(), (ct: Context) => { + received.push(ct.request.body); + return ct.send(); + }); + const send = async (charset: string, body: Buffer): Promise => { + const headers = { "Content-Type": `${ct_text}; charset=${charset}` }; + const response = await request(url, { method: "POST", body, headers }); + await response.body.text(); + return response.statusCode; + }; + + // `café` + deepEqual( + await send("iso-8859-1", Buffer.from([0x63, 0x61, 0x66, 0xe9])), + 200, + ); + deepEqual(await send("utf-8", Buffer.from("café")), 200); + deepEqual(await send("unknown-charset", Buffer.from("café")), 415); + deepEqual(received, ["café", "café"]); + }); + test("Unsupported methods", async () => { const assert = (ct: Context): Promise => { ok(typeof ct.request.body === "undefined"); From 577fc00dcc2fbe393e6fabf767ca368461f9d497 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 04:11:59 +0300 Subject: [PATCH 097/675] fix!: reject a body that the custom parse cannot parse in @binden/body A custom `parse` (for `application/json`) that returned `undefined` was taken as successful: the parsers of `ajv` (`compileParser()`), as in the example of the README, return `undefined` for an invalid body, which then reached the next middlewares as `undefined`, with a 200. `undefined` from `parse` is now a body that it cannot parse: a `415 Unsupported Media Type`, whose cause has the `message` of the parser when it has one. The README describes it, and lists `text/plain` (not `plain/text`) among the content types. BREAKING CHANGE: a custom `parse` that returns `undefined` makes `BodyParser` reply 415. Co-Authored-By: Claude Opus 5.5 --- packages/body/README.md | 6 +++++- packages/body/index.ts | 17 ++++++++++++++- packages/body/test/index.test.ts | 37 ++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/body/README.md b/packages/body/README.md index 70094125..be34782e 100644 --- a/packages/body/README.md +++ b/packages/body/README.md @@ -4,7 +4,7 @@ - `application/json` - `application/x-www-form-urlencoded` -- `plain/text` +- `text/plain` and any combination of the following [encodings](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding) @@ -50,6 +50,10 @@ const body_parser = new BodyParser({ limit: 1024 * 1024 }); ### Custom `parse` function +`parse` replaces `JSON.parse()` for `application/json`. A body that it cannot parse (it throws, or +returns `undefined`, as the parsers of [Ajv](https://ajv.js.org/guide/getting-started.html#parsing-and-serializing-json) +do) gets a `415 Unsupported Media Type`. + ```typescript import { Binden, type Context, Router } from "binden"; import { Ajv, type JTDDataType } from "ajv/dist/jtd.js"; diff --git a/packages/body/index.ts b/packages/body/index.ts index a59ebdbf..d9a6f7ea 100644 --- a/packages/body/index.ts +++ b/packages/body/index.ts @@ -158,7 +158,7 @@ export class BodyParser extends Middleware { try { request.body = type === ct_json - ? this.#parse(body) + ? this.#json(body) : type === ct_form ? new URLSearchParams(body) : body; @@ -173,6 +173,21 @@ export class BodyParser extends Middleware { } } + /** + * The value of a JSON body: `parse` throws, or returns `undefined` (e.g. a + * parser of `ajv`, which tells why in its `message`), when it cannot parse it + */ + #json(body: string): T { + const parsed = this.#parse(body); + if (typeof parsed === "undefined") { + const { message } = this.#parse as { message?: unknown }; + throw new Error( + typeof message === "string" ? message : "The body could not be parsed", + ); + } + return parsed; + } + /** Set of methods with no request body */ public static get unsupported_methods(): Set { return new Set(BodyParser.#methods); diff --git a/packages/body/test/index.test.ts b/packages/body/test/index.test.ts index 8fd1b09f..956cae1b 100644 --- a/packages/body/test/index.test.ts +++ b/packages/body/test/index.test.ts @@ -252,6 +252,43 @@ suite("BodyParser", () => { deepEqual(text, ""); }); + test("JSON (a custom `parse` that cannot parse the body)", async () => { + const ajv = new AJV(); + const schema = { + properties: { username: { type: "string" } }, + additionalProperties: false, + } as const; + const parsers: [string, IParse][] = [ + ["/ajv", ajv.compileParser>(schema)], + // Returns `undefined` + ["/undefined", (): unknown => Reflect.get({}, "nothing")], + ]; + const received: unknown[] = []; + for (const [path, parse] of parsers) { + app.use(path, new BodyParser({ parse }), (ct: Context) => { + received.push(ct.request.body); + return ct.send(); + }); + } + const send = async (path: string, body: string): Promise => { + const headers = { "Content-Type": ct_json }; + const response = await request(new URL(path, url), { + method: "POST", + body, + headers, + }); + await response.body.text(); + return response.statusCode; + }; + + // `undefined` is a failure, which `ajv` explains in its `message` + deepEqual(await send("/ajv", `{"username":1}`), 415); + deepEqual(await send("/ajv", `{"x":1}`), 415); + deepEqual(await send("/ajv", `{"username":"a"}`), 200); + deepEqual(await send("/undefined", "{}"), 415); + deepEqual(received, [{ username: "a" }]); + }); + test("JSON (invalid)", async () => { app.use(new BodyParser()); From d6f8175da2188b28c902969bd1ab04248fe33564 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 04:12:25 +0300 Subject: [PATCH 098/675] metadata!: change the @binden/body license to MIT Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- package-lock.json | 2 +- packages/body/LICENSE | 682 ++----------------------------------- packages/body/package.json | 2 +- 4 files changed, 24 insertions(+), 664 deletions(-) diff --git a/README.md b/README.md index dc3c2f3c..5c1bfa29 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ the same version. | --------------------------------------------- | --------------------------------------- | ------------- | | [`binden`](packages/binden) | A simple HTTP server framework | MIT | | [`@binden/logger`](packages/logger) | A simple logger (based on `pino`) | MIT | -| [`@binden/body`](packages/body) | Body parser middleware | AGPL-3.0-only | +| [`@binden/body`](packages/body) | Body parser middleware | MIT | | [`@binden/compression`](packages/compression) | Compression middleware | AGPL-3.0-only | | [`@binden/multipart`](packages/multipart) | `multipart/form-data` parser middleware | AGPL-3.0-only | diff --git a/package-lock.json b/package-lock.json index bde2b681..9eedf60c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9137,7 +9137,7 @@ "packages/body": { "name": "@binden/body", "version": "0.0.0-development", - "license": "AGPL-3.0-only", + "license": "MIT", "devDependencies": { "@binden/logger": "^0.0.0-development", "ajv": "^8.20.0", diff --git a/packages/body/LICENSE b/packages/body/LICENSE index 162676cb..d542516c 100644 --- a/packages/body/LICENSE +++ b/packages/body/LICENSE @@ -1,661 +1,21 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - -Copyright (C) 2007 Free Software Foundation, Inc. -Everyone is permitted to copy and distribute verbatim copies -of this license document, but changing it is not allowed. - - Preamble - -The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - -The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - -When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - -Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - -A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - -The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - -An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - -The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - -0. Definitions. - -"This License" refers to version 3 of the GNU Affero General Public License. - -"Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - -"The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - -To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - -A "covered work" means either the unmodified Program or a work based -on the Program. - -To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - -To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - -An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - -1. Source Code. - -The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - -A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - -The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - -The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - -The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - -The Corresponding Source for a work in source code form is that -same work. - -2. Basic Permissions. - -All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - -You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - -Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - -3. Protecting Users' Legal Rights From Anti-Circumvention Law. - -No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - -When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - -4. Conveying Verbatim Copies. - -You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - -You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - -5. Conveying Modified Source Versions. - -You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - -A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - -6. Conveying Non-Source Forms. - -You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - -A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - -A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - -"Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - -If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - -The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - -Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - -7. Additional Terms. - -"Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - -When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - -Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - -All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - -If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - -Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - -8. Termination. - -You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - -However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - -Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - -Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - -9. Acceptance Not Required for Having Copies. - -You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - -10. Automatic Licensing of Downstream Recipients. - -Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - -An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - -You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - -11. Patents. - -A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - -A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - -Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - -In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - -If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - -If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - -A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - -Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - -12. No Surrender of Others' Freedom. - -If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - -13. Remote Network Interaction; Use with the GNU General Public License. - -Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - -Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - -14. Revised Versions of this License. - -The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - -If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - -Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - -15. Disclaimer of Warranty. - -THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. Limitation of Liability. - -IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - -17. Interpretation of Sections 15 and 16. - -If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - -If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - -To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - -If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - -You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. +MIT License + +Copyright (c) 2026 Sergey Bakulin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/body/package.json b/packages/body/package.json index be2a7d38..84766ae3 100644 --- a/packages/body/package.json +++ b/packages/body/package.json @@ -30,7 +30,7 @@ "body" ], "author": "Sergey Bakulin ", - "license": "AGPL-3.0-only", + "license": "MIT", "bugs": { "url": "https://github.com/binden-js/binden/issues" }, From 84f8215e682761000032d42d1eeb47bea635367f Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 04:12:28 +0300 Subject: [PATCH 099/675] metadata!: change the @binden/compression license to MIT Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- package-lock.json | 2 +- packages/compression/LICENSE | 682 +----------------------------- packages/compression/package.json | 2 +- 4 files changed, 24 insertions(+), 664 deletions(-) diff --git a/README.md b/README.md index 5c1bfa29..54d05e00 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ the same version. | [`binden`](packages/binden) | A simple HTTP server framework | MIT | | [`@binden/logger`](packages/logger) | A simple logger (based on `pino`) | MIT | | [`@binden/body`](packages/body) | Body parser middleware | MIT | -| [`@binden/compression`](packages/compression) | Compression middleware | AGPL-3.0-only | +| [`@binden/compression`](packages/compression) | Compression middleware | MIT | | [`@binden/multipart`](packages/multipart) | `multipart/form-data` parser middleware | AGPL-3.0-only | API documentation: diff --git a/package-lock.json b/package-lock.json index 9eedf60c..a8453bc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9168,7 +9168,7 @@ "packages/compression": { "name": "@binden/compression", "version": "0.0.0-development", - "license": "AGPL-3.0-only", + "license": "MIT", "devDependencies": { "undici": "^8.3.0" }, diff --git a/packages/compression/LICENSE b/packages/compression/LICENSE index 162676cb..d542516c 100644 --- a/packages/compression/LICENSE +++ b/packages/compression/LICENSE @@ -1,661 +1,21 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - -Copyright (C) 2007 Free Software Foundation, Inc. -Everyone is permitted to copy and distribute verbatim copies -of this license document, but changing it is not allowed. - - Preamble - -The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - -The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - -When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - -Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - -A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - -The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - -An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - -The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - -0. Definitions. - -"This License" refers to version 3 of the GNU Affero General Public License. - -"Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - -"The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - -To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - -A "covered work" means either the unmodified Program or a work based -on the Program. - -To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - -To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - -An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - -1. Source Code. - -The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - -A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - -The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - -The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - -The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - -The Corresponding Source for a work in source code form is that -same work. - -2. Basic Permissions. - -All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - -You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - -Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - -3. Protecting Users' Legal Rights From Anti-Circumvention Law. - -No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - -When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - -4. Conveying Verbatim Copies. - -You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - -You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - -5. Conveying Modified Source Versions. - -You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - -A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - -6. Conveying Non-Source Forms. - -You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - -A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - -A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - -"Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - -If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - -The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - -Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - -7. Additional Terms. - -"Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - -When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - -Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - -All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - -If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - -Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - -8. Termination. - -You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - -However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - -Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - -Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - -9. Acceptance Not Required for Having Copies. - -You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - -10. Automatic Licensing of Downstream Recipients. - -Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - -An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - -You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - -11. Patents. - -A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - -A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - -Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - -In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - -If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - -If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - -A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - -Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - -12. No Surrender of Others' Freedom. - -If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - -13. Remote Network Interaction; Use with the GNU General Public License. - -Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - -Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - -14. Revised Versions of this License. - -The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - -If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - -Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - -15. Disclaimer of Warranty. - -THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. Limitation of Liability. - -IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - -17. Interpretation of Sections 15 and 16. - -If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - -If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - -To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - -If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - -You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. +MIT License + +Copyright (c) 2026 Sergey Bakulin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/compression/package.json b/packages/compression/package.json index 48d2a12c..7a6f460a 100644 --- a/packages/compression/package.json +++ b/packages/compression/package.json @@ -33,7 +33,7 @@ "binden" ], "author": "Sergey Bakulin ", - "license": "AGPL-3.0-only", + "license": "MIT", "bugs": { "url": "https://github.com/binden-js/binden/issues" }, From 1cfb76a31bcdc630879583b88d0c42ff35c41ede Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 04:12:31 +0300 Subject: [PATCH 100/675] metadata!: change the @binden/multipart license to MIT Co-Authored-By: Claude Opus 5.5 --- README.md | 14 +- package-lock.json | 2 +- packages/multipart/LICENSE | 682 +------------------------------- packages/multipart/package.json | 2 +- 4 files changed, 30 insertions(+), 670 deletions(-) diff --git a/README.md b/README.md index 54d05e00..97afd06f 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ A simple HTTP server framework and its official packages. All packages live in t [npm workspaces](https://docs.npmjs.com/cli/using-npm/workspaces) and are released together with the same version. -| Package | Description | License | -| --------------------------------------------- | --------------------------------------- | ------------- | -| [`binden`](packages/binden) | A simple HTTP server framework | MIT | -| [`@binden/logger`](packages/logger) | A simple logger (based on `pino`) | MIT | -| [`@binden/body`](packages/body) | Body parser middleware | MIT | -| [`@binden/compression`](packages/compression) | Compression middleware | MIT | -| [`@binden/multipart`](packages/multipart) | `multipart/form-data` parser middleware | AGPL-3.0-only | +| Package | Description | License | +| --------------------------------------------- | --------------------------------------- | ------- | +| [`binden`](packages/binden) | A simple HTTP server framework | MIT | +| [`@binden/logger`](packages/logger) | A simple logger (based on `pino`) | MIT | +| [`@binden/body`](packages/body) | Body parser middleware | MIT | +| [`@binden/compression`](packages/compression) | Compression middleware | MIT | +| [`@binden/multipart`](packages/multipart) | `multipart/form-data` parser middleware | MIT | API documentation: diff --git a/package-lock.json b/package-lock.json index a8453bc4..09307e8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9213,7 +9213,7 @@ "packages/multipart": { "name": "@binden/multipart", "version": "0.0.0-development", - "license": "AGPL-3.0-only", + "license": "MIT", "dependencies": { "busboy": "^1.6.0" }, diff --git a/packages/multipart/LICENSE b/packages/multipart/LICENSE index 162676cb..d542516c 100644 --- a/packages/multipart/LICENSE +++ b/packages/multipart/LICENSE @@ -1,661 +1,21 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - -Copyright (C) 2007 Free Software Foundation, Inc. -Everyone is permitted to copy and distribute verbatim copies -of this license document, but changing it is not allowed. - - Preamble - -The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - -The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - -When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - -Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - -A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - -The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - -An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - -The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - -0. Definitions. - -"This License" refers to version 3 of the GNU Affero General Public License. - -"Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - -"The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - -To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - -A "covered work" means either the unmodified Program or a work based -on the Program. - -To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - -To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - -An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - -1. Source Code. - -The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - -A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - -The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - -The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - -The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - -The Corresponding Source for a work in source code form is that -same work. - -2. Basic Permissions. - -All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - -You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - -Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - -3. Protecting Users' Legal Rights From Anti-Circumvention Law. - -No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - -When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - -4. Conveying Verbatim Copies. - -You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - -You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - -5. Conveying Modified Source Versions. - -You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - -A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - -6. Conveying Non-Source Forms. - -You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - -A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - -A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - -"Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - -If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - -The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - -Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - -7. Additional Terms. - -"Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - -When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - -Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - -All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - -If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - -Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - -8. Termination. - -You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - -However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - -Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - -Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - -9. Acceptance Not Required for Having Copies. - -You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - -10. Automatic Licensing of Downstream Recipients. - -Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - -An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - -You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - -11. Patents. - -A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - -A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - -Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - -In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - -If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - -If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - -A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - -Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - -12. No Surrender of Others' Freedom. - -If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - -13. Remote Network Interaction; Use with the GNU General Public License. - -Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - -Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - -14. Revised Versions of this License. - -The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - -If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - -Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - -15. Disclaimer of Warranty. - -THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. Limitation of Liability. - -IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - -17. Interpretation of Sections 15 and 16. - -If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - -If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - -To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - -If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - -You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. +MIT License + +Copyright (c) 2026 Sergey Bakulin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/multipart/package.json b/packages/multipart/package.json index 9341778d..5e51521a 100644 --- a/packages/multipart/package.json +++ b/packages/multipart/package.json @@ -31,7 +31,7 @@ "busboy" ], "author": "Sergey Bakulin ", - "license": "AGPL-3.0-only", + "license": "MIT", "bugs": { "url": "https://github.com/binden-js/binden/issues" }, From 8cf23151b8603cebb41f4ef2b4de97bae0a986f2 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 04:14:41 +0300 Subject: [PATCH 101/675] docs: describe the routing, the request-target and the replies of binden - The binden README has a Router section (the methods, `HEAD`, `guarded` routers and their 405, and `CONNECT`, which never reaches the middlewares), and describes the contract of `error_handler`, the replacement of the context, the URL of a request (and the request-targets that get 400), the framing of `send()`, the preconditions of `sendFile()`, the logged path, the status codes and the validation of the headers. - The examples of `.disabled` and `.ignore_errors` start their timer once, instead of in every request. - The compression README tells that a compressed response has a weak `ETag`. - The root README tells that the README examples are type checked only. - `SECURITY.md` asks to report vulnerabilities privately. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++ SECURITY.md | 12 ++++ packages/binden/README.md | 110 ++++++++++++++++++++++----------- packages/compression/README.md | 2 + 4 files changed, 91 insertions(+), 37 deletions(-) create mode 100644 SECURITY.md diff --git a/README.md b/README.md index 97afd06f..36ec582e 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ npm run ci:all # everything CI runs Commands for a single package: `npm test -w packages/binden`. +`npm run consumer:check` runs a consumer of the packed packages against a real server, but only +type checks the TypeScript examples of the READMEs: the placeholders that they use (`app`, +`context`, …) are declared, and their behaviour is not tested. + Packages consume each other through the compiled `dist/` (`@binden/body`, `@binden/compression` and `@binden/multipart` import `binden`, which imports `@binden/logger`): run `npm run build` after changing a package that others depend on. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..82c6bc47 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,12 @@ +# Security policy + +## Supported versions + +Only the latest release of the packages of this repository (`binden`, `@binden/logger`, +`@binden/body`, `@binden/compression` and `@binden/multipart`) gets security fixes. + +## Reporting a vulnerability + +Please do not report vulnerabilities in public issues. Report them privately through +[GitHub](https://github.com/binden-js/binden/security/advisories/new) (Security → Report a +vulnerability), with the affected package and version, and the steps to reproduce. diff --git a/packages/binden/README.md b/packages/binden/README.md index 8ffd1228..288f558d 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -14,8 +14,8 @@ npm install binden `new Binden(options)` takes the following options: -- `auto_head` (default `true`) - a `guarded` router that handles `GET` handles `HEAD` as well -- `error_handler` - replaces the default reply to an error thrown by a middleware. It may be `async`: it is awaited, and the default reply is sent when it has not started a response. When it fails, the connection is closed. +- `auto_head` (default `true`) - a router without `HEAD` middlewares handles `HEAD` with its `GET` middlewares (see [Router](#router)) +- `error_handler` - replaces the default reply to an error thrown by a middleware (the thrown value may be anything, also `null`). It may be `async`: it is awaited, and it must have ended its reply when it settles. Nothing started then gets the default reply, while a reply that is not ended (e.g. a `write()` without `end()`) is cut by closing the connection. When it fails, the connection is closed. There is no deadline: a handler that never settles keeps its request. - `logger` - see [Logging](#logging) - `trust_proxy` (default `false`) - the proxies in front of the server whose [`Forwarded`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded) elements `request.protocol` trusts: none (`false`), all of them (`true`), or the given number of the nearest ones (each proxy appends its element, so the leftmost ones come from the client) @@ -32,6 +32,8 @@ const middleware3 = (context: Context): Promise => app.use("/path2", middleware3); ``` +A RegExp path keeps its flags, except `g` and `y` (they would make `test()` stateful). + - `.off()` - remove a `Middleware/Router` from the stack (returns the removed items) ```typescript @@ -66,7 +68,7 @@ const secureServer = app.createSecureServer({ key, cert }); Every context logs through `context.log`: a child (with the `trace_id` of the context) of the logger of the application. The default logger (see [`@binden/logger`](https://github.com/binden-js/binden/tree/main/packages/logger)) is silent unless the `BINDEN_LOG_LEVEL` environment variable sets a level, and its `serializers` log an allowlist of details only, which keeps credentials and the input of the requests out of the logs: -- a request is its method, its path (without the query string) and its `Host`, `User-Agent`, `Content-Type` and `Content-Length` headers; +- a request is its method, its path and its `Host`, `User-Agent`, `Content-Type` and `Content-Length` headers. The path is logged without the query string and the fragment, nor the scheme, authority and userinfo of an absolute-form request-target; anything secret in the path itself (e.g. a token) is logged; - a response is its status code and its `Content-Type` and `Content-Length` headers; - a context is its `id`, request and response (not the fields the application sets on it); - a logged `body` (e.g. the one that [`@binden/body`](https://github.com/binden-js/binden/tree/main/packages/body) failed to parse) is redacted; @@ -300,17 +302,14 @@ export class MyMiddleware1 extends Middleware { const mm1 = new MyMiddleware1({ disabled: true }); -export class MyMiddleware2 extends Middleware { - public async run(): Promise { - // Disable `mm1` every hour - setInterval( - () => { - mm1.disabled = !mm1.disabled; - }, - 1000 * 60 * 60, - ); - } -} +// Enable or disable `mm1` every hour +setInterval( + () => { + mm1.disabled = !mm1.disabled; + }, + 1000 * 60 * 60, +).unref(); +app.use(mm1); ``` - `.ignore_errors` - ignore errors from `await this.run(context)` @@ -329,19 +328,49 @@ export class MyMiddleware1 extends Middleware { const mm1 = new MyMiddleware1({ ignore_errors: true }); -export class MyMiddleware2 extends Middleware { - public async run(): Promise { - // Throw errors from `mm1.run()` every minute - setInterval(() => { - mm1.ignore_errors = !mm1.ignore_errors; - }, 1000 * 60); - } -} +// Throw the errors of `mm1.run()` every other minute +setInterval(() => { + mm1.ignore_errors = !mm1.ignore_errors; +}, 1000 * 60).unref(); +app.use(mm1); +``` + +A middleware may return a context, which replaces the current one for the following middlewares: it must have the request and the response of the current context (otherwise the middleware fails with a `TypeError`). + +### Router + +A `Router` holds middlewares by method, for a path of the stack: + +```typescript +import { Binden, type Context, Router } from "binden"; + +const router = new Router() + .get((context: Context) => context.json({ items: [] })) + .post((context: Context) => context.status(201).send()) + .on("PROPFIND", (context: Context) => context.status(207).send()); +const app = new Binden().use("/items", router); +``` + +- `.get()`, `.head()`, `.post()`, `.put()`, `.patch()`, `.delete()`, `.options()`, `.trace()`, and `.on()` for any other method of Node.js but `CONNECT`, which Node.js emits as a `connect` event of the server: its requests never reach the middlewares. `.off()` removes middlewares, and `.methods` lists the methods that have some. +- `HEAD` is handled by the `HEAD` middlewares of a router, or else by its `GET` middlewares (unless the `auto_head` option of `Binden` is `false`). A response to `HEAD` sends no body. +- A `guarded` router (`new Router({ guarded: true })`) that does not allow the method of a request passes it on. When nothing else handles the request, it gets `405 Method Not Allowed`, with the methods of every guarded router that has refused it in `Allow`. + +```typescript +import { Binden, type Context, Router } from "binden"; + +const list = new Router({ guarded: true }).get((context: Context) => + context.send("list"), +); +const create = new Router({ guarded: true }).post((context: Context) => + context.status(201).send(), +); +// `DELETE /items` gets 405, with `Allow: GET, HEAD, POST` +const app = new Binden().use("/items", list, create); ``` ### BindenError -`BindenError` represents an HTTP error +`BindenError` represents an HTTP error, with a status code from 400 to 599 (an integer: anything else throws a `TypeError`) ```typescript import { Middleware, Context, BindenError } from "binden"; @@ -397,7 +426,7 @@ const rawHeader = request.header("X-Header"); const { id } = request; ``` -- `.protocol` - the protocol of the connection or, behind trusted proxies (the `trust_proxy` option of `Binden`), the `proto` of the [Forwarded](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded) element of the outermost of them. With `http.createServer()`, override the protected `trust_proxy` getter in a subclass of `BindenRequest`: +- `.protocol` - the protocol of the connection or, behind trusted proxies (the `trust_proxy` option of `Binden`), the `proto` of the [Forwarded](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded) element of the outermost of them: the element in its position (counted from the nearest proxy), and the protocol of the connection when that element is invalid or has no `proto`. With `http.createServer()`, override the protected `trust_proxy` getter in a subclass of `BindenRequest`: ```typescript const { protocol } = request; @@ -415,6 +444,12 @@ if (!secure) { } ``` +- `.URL` - the URL of the request: the protocol of the connection (`.protocol`), the authority of an absolute-form request-target (otherwise of `Host`), and the path and query of the request-target, set as such (`//a/b` is a path). A request-target with a fragment, a backslash, an invalid percent-encoding or a dot-segment (`/a/../b`, also percent-encoded, which would route the request to another path), and a `Host` that is not a host and a port, throw a `BindenError` with the status 400 (which `Binden` replies) + +```typescript +const { pathname } = request.URL; +``` + - `.query` - A copy of `URL.search` parsed by the `querystring.parse()` method ```typescript @@ -450,13 +485,13 @@ response.cookies.add(cookie); await response.send("Check the `Set-Cookie` header for a random UUID"); ``` -- `.status()` - Set the status code of the response +- `.status()` - Set the status code of the response: an integer from 100 to 599 ```typescript await response.status(400).send(); ``` -- `.set()` - Set the headers +- `.set()` - Set the headers (the own properties of the object) ```typescript const headers = { @@ -466,7 +501,7 @@ const headers = { await response.status(402).set(headers).send("Payment is required"); ``` -- `.send()` - send data. A response without a body (to `HEAD`, or with a 1xx, 204, 205 or 304 status) sends none: a `Readable` is destroyed without being read, and a response to `HEAD` gets the `Content-Length` of the body it would send to `GET` +- `.send()` - send data, framed by the final status: a body gets its exact `Content-Length` (also a stale one is replaced), 1xx, 204 and 304 responses have none, and 205 has `Content-Length: 0`. A response without a body (to `HEAD`, or with a 1xx, 204, 205 or 304 status) sends none: a `Readable` is destroyed without being read, and a response to `HEAD` gets the `Content-Length` of the body it would send to `GET`. It resolves once the response is flushed to the connection, and rejects when it cannot be sent (e.g. the client went away), or when a `Readable` fails, closes before its end, or does not have its `Content-Length`. Nothing sent yet, such an error can still be replied; otherwise the response is cut, so that the client knows that it is truncated. ```typescript await response.send( @@ -528,22 +563,20 @@ const form = new URLSearchParams([ await response.form(form); ``` -- `.sendFile()` - Send a file with its `Last-Modified` (never in the future). Follows [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html) for the following headers - - [If-Modified-Since](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since) - `304 Not Modified` for `GET` and `HEAD`, unless `If-None-Match` is sent +- `.sendFile()` - Send a file with its `Last-Modified` (never in the future). Its metadata and its content are read from the one file it opens (a FIFO is not waited for), and no more than its size is sent. Follows [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html) for the following headers, with the `ETag` set on the response (if any), in this order + - [If-Match](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match) (or else [If-Unmodified-Since](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Unmodified-Since)) - `412 Precondition Failed` when it does not match (by the strong comparison; `*` matches the file) + + - [If-None-Match](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match) (or else [If-Modified-Since](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since), for `GET` and `HEAD`) - when it matches (by the weak comparison), `304 Not Modified` for `GET` and `HEAD`, `412 Precondition Failed` for other methods - [Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range) - for `GET` only: `206 Partial Content` for one range (overlapping or adjacent ranges are merged; several distinct ranges get the whole file), `416 Range Not Satisfiable` when no range can be satisfied - - [If-Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range) - the ranges apply when it is exactly the `Last-Modified`, or the strong `ETag` set on the response + - [If-Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range) - the ranges apply when it is the strong `ETag` set on the response, or exactly the `Last-Modified` when that is at least a second old (a strong validator) A response to `HEAD` gets the headers of the whole file, without reading it. ```typescript const path = ""; await response.sendFile(path); -// Or with custom Stats -import { stat } from "node:fs/promises"; -const stats = await stat(""); -await response.sendFile(path, stats); ``` ### Headers @@ -580,7 +613,9 @@ const encodings = ContentEncoding.fromString( const { content_encoding } = request; ``` -- [Content-Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range) +The constructors of the headers throw a `TypeError` for what their parsers reject (e.g. a reversed `Range`, a weight that is not a qvalue, or a `Content-Type` parameter with control characters). + +- [Content-Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range) - `bytes -/` (the size may be `*`) or an unsatisfied range, with decimal positions only ```typescript import { ContentRange } from "binden"; @@ -589,7 +624,7 @@ const cr = new ContentRange({ start: 0, end: 499, size: 1000 }); response.setHeader("Content-Range", cr.toString()); ``` -- [Content-Type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) +- [Content-Type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) - a media type and all of its parameters (`.parameters`, by lowercase name), serialized as quoted strings when they are not tokens ```typescript import { ContentType } from "binden"; @@ -622,11 +657,12 @@ const cookie2 = new Cookie({ response.cookies.add(cookie1).add(cookie2); ``` -- [Forwarded](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded) +- [Forwarded](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded) - `Forwarded.parse()` returns the elements in their positions (`null` for an invalid one: a parameter that is not `name=value`, or that is given twice), and `Forwarded.fromString()` the valid ones ```typescript import { Forwarded } from "binden"; +const elements = Forwarded.parse(request.headers.forwarded); const proxies = Forwarded.fromString(request.headers.forwarded); // or using BindenRequest const { forwarded } = request; diff --git a/packages/compression/README.md b/packages/compression/README.md index 9bff719d..e0432692 100644 --- a/packages/compression/README.md +++ b/packages/compression/README.md @@ -28,3 +28,5 @@ With the default `format: "auto"`, the content coding is chosen from the `Accept A fixed `format` (`br`, `gzip` or `deflate`) compresses every response regardless of the request. Partial responses (`206 Partial Content`, or any response with a `Content-Range` header, such as `416 Range Not Satisfiable`) are never compressed, because their ranges refer to the uncompressed body. Neither are responses without a body (1xx, `204`, `205`, `304`) or whose headers have already been sent. A response to `HEAD` gets the headers of the compressed response, without a body. + +A strong `ETag` of a compressed response becomes weak (`W/"…"`): it was set for the uncompressed body, whose bytes differ. It still validates caches (`If-None-Match` compares weakly), but no longer ranges and `If-Match`. From b2aaab7851db0ecd3d3b3cfdb371a2ee73b63836 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 04:19:12 +0300 Subject: [PATCH 102/675] fix: recover a partially published release safely semantic-release pushes the tag of a release before the packages are published. When publishing stops half-way, the next release starts after that tag, and the documented recovery (`publish --skip-existing`) published the rest from whatever was checked out, without checking that the packages already published came from the same commit, and without the GitHub Release (the changelog). `publish --skip-existing` now refuses to run unless HEAD is the commit of the tag of the version, and refuses a package already published from another commit (its `gitHead` on npm). The CI workflow has a `recover` job, run by hand with the version, which checks out the tag, publishes the packages that are not published yet with provenance, and creates the GitHub Release when it is missing. The README describes the order of a release and its recovery. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 67 +++++++++++++++++++++++++++ README.md | 39 +++++++++++----- scripts/release.mjs | 34 +++++++++++++- scripts/release.test.mjs | 97 +++++++++++++++++++++++++++++++++++++--- 4 files changed, 219 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ec6fa0d..d0d36c8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,13 @@ on: push: branches: [main] pull_request: + # Recovers a release that failed after its tag was pushed (see "Releases" in the README) + workflow_dispatch: + inputs: + version: + description: The version of the release to recover (its tag v exists) + required: true + type: string permissions: contents: read @@ -16,6 +23,8 @@ concurrency: jobs: lint: + if: github.event_name != 'workflow_dispatch' + runs-on: ubuntu-latest timeout-minutes: 15 @@ -68,6 +77,8 @@ jobs: path: docs test: + if: github.event_name != 'workflow_dispatch' + runs-on: ubuntu-latest timeout-minutes: 15 @@ -101,6 +112,8 @@ jobs: # The tests and the tooling (paths, line endings, child processes) on Windows windows: + if: github.event_name != 'workflow_dispatch' + runs-on: windows-latest timeout-minutes: 20 @@ -167,6 +180,60 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: true + # Publishes the packages of a release that are not published yet, from the commit of its + # tag (`scripts/release.mjs` checks that the published ones come from it), and creates its + # GitHub Release when it is missing. Run from the Actions tab (in this workflow, which npm + # trusts for publishing). + recover: + if: github.event_name == 'workflow_dispatch' + + runs-on: ubuntu-latest + + timeout-minutes: 30 + + permissions: + contents: write + id-token: write + + env: + HUSKY: 0 + VERSION: ${{ inputs.version }} + + steps: + - uses: actions/checkout@v7 + with: + ref: v${{ inputs.version }} + fetch-depth: 0 + + - uses: actions/setup-node@v7 + with: + node-version: 24 + check-latest: true + registry-url: https://registry.npmjs.org + + - name: Install dependencies and build the workspaces + run: npm ci + + - name: Publish the packages that are not published yet + run: | + node scripts/release.mjs version "$VERSION" + node scripts/release.mjs publish --skip-existing + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: true + + # Its notes are generated by GitHub (from the merged pull requests) rather than + # semantic-release + - name: Create the GitHub Release when it is missing + run: | + if ! gh release view "v$VERSION" > /dev/null 2>&1; then + previous=$(git describe --tags --abbrev=0 --match 'v*' "v$VERSION^") + gh release create "v$VERSION" --verify-tag --title "v$VERSION" \ + --generate-notes --notes-start-tag "$previous" + fi + env: + GH_TOKEN: ${{ github.token }} + pages: if: github.event_name == 'push' && github.ref == 'refs/heads/main' diff --git a/README.md b/README.md index 36ec582e..5a609b42 100644 --- a/README.md +++ b/README.md @@ -74,12 +74,15 @@ They do not affect the published packages. Releases are fully automated by [semantic-release](https://github.com/semantic-release/semantic-release) on every push to `main` (see [`.releaserc.json`](.releaserc.json)): -- the next version is derived from [Conventional Commits](https://conventionalcommits.org) since the - last `v*` tag; -- [`scripts/release.mjs`](scripts/release.mjs) sets that version in **every** workspace (and the - internal dependency ranges to `^`) and publishes all packages to npm in dependency order; -- the `v` tag and a [GitHub Release](https://github.com/binden-js/binden/releases) with the - release notes are created: they are the changelog (see [`CHANGELOG.md`](CHANGELOG.md)). +1. the next version is derived from [Conventional Commits](https://conventionalcommits.org) since + the last `v*` tag, and [`scripts/release.mjs`](scripts/release.mjs) checks that no workspace is + published at it yet; +2. `scripts/release.mjs` sets that version in **every** workspace (and the internal dependency + ranges to `^`); +3. the `v` tag is pushed; +4. `scripts/release.mjs` publishes all packages to npm in dependency order; +5. a [GitHub Release](https://github.com/binden-js/binden/releases) with the release notes is + created: they are the changelog (see [`CHANGELOG.md`](CHANGELOG.md)). Nothing is committed back to `main`, as [recommended by semantic-release](https://semantic-release.gitbook.io/semantic-release/support/faq): @@ -87,10 +90,26 @@ a release commit would have to bypass the branch protection of `main`. The versi repository therefore stay at `0.0.0-development` (and the internal ranges at `^0.0.0-development`); the published version is set only in the checkout of the release job. -`npm run release:check` verifies that all workspaces share one version. If a release fails after -some packages were published, run `node scripts/release.mjs version ` and then -`node scripts/release.mjs publish --skip-existing` (without committing the result) to publish the -rest. +`npm run release:check` verifies that all workspaces share one version. + +### Recovering a release + +When a release fails after its tag was pushed (for example, some packages were published, but not +all of them), the next release would start after that tag: recover it instead, by running the CI +workflow manually (Actions → CI → Run workflow) with its version. The `recover` job checks out +the commit of the tag, publishes the packages that are not published yet (with provenance), and +creates the GitHub Release when it is missing (with notes generated by GitHub). It refuses to +publish from another commit than the one of the tag, and when an already published package was +published from another commit (its `gitHead` on npm). + +The same can be done by hand from a checkout of the tag (packages published from outside CI have no +provenance): + +```bash +git checkout v +node scripts/release.mjs version +node scripts/release.mjs publish --skip-existing +``` The `CHANGELOG.md` files inside `packages/*` describe the history before the packages were merged into this repository. diff --git a/scripts/release.mjs b/scripts/release.mjs index 26d76fcc..4d8ab629 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -5,12 +5,15 @@ // verify none of the workspaces is published at yet // version set everywhere and refresh package-lock.json // publish publish every workspace in the `workspaces` order -// (`--skip-existing` skips already published ones, for manual recovery) +// (`--skip-existing` skips already published ones, to recover a release +// that failed half-way: from the commit of its tag only, and only when the +// published ones come from that commit too) // // Provenance is enabled by the CI release job (NPM_CONFIG_PROVENANCE), so a manual // `publish --skip-existing` works outside of CI too. // Everything is logged to stderr: @semantic-release/exec parses the stdout of `publishCmd`. import { readFileSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; import { npm as execNpm } from "./npm.mjs"; import { join } from "node:path"; @@ -123,10 +126,39 @@ function setVersion(version) { check(); } +function git(args) { + return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); +} + +// The commit that a published version was published from (npm records it as `gitHead`) +function publishedFrom(name, version) { + const stdout = npm(["view", `${name}@${version}`, "gitHead", "--json"], { + stdio: ["ignore", "pipe", "pipe"], + }); + return stdout.trim() === "" ? null : JSON.parse(stdout); +} + function publish(skipExisting) { check(); + const [{ manifest: first }] = workspaces(); + const head = skipExisting ? git(["rev-parse", "HEAD"]) : null; + if (skipExisting) { + // The tag of a release is pushed before its packages are published: the rest is + // published from the commit of the tag, not from a later one + const tag = `v${first.version}`; + const tagged = git(["rev-parse", "--verify", `${tag}^{commit}`]); + if (tagged !== head) { + throw new Error(`HEAD (${head}) is not the commit of ${tag} (${tagged})`); + } + } for (const { dir, manifest } of workspaces()) { if (skipExisting && isPublished(manifest.name, manifest.version)) { + const from = publishedFrom(manifest.name, manifest.version); + if (from !== head) { + throw new Error( + `${manifest.name}@${manifest.version} was published from ${from}, not from ${head}`, + ); + } log(`skip ${manifest.name}@${manifest.version}: already published`); } else { log(`publish ${manifest.name}@${manifest.version}`); diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs index 132e7640..c8e5dbcb 100644 --- a/scripts/release.test.mjs +++ b/scripts/release.test.mjs @@ -15,8 +15,9 @@ import { tmpdir } from "node:os"; // A fake npm CLI (run as `npm_execpath`, see scripts/npm.mjs) that logs its // arguments and emulates `npm view` (E404 unless the package version is listed in -// FAKE_NPM_PUBLISHED, or a network error with FAKE_NPM_OFFLINE). Every other -// command succeeds without doing anything. +// FAKE_NPM_PUBLISHED, or a network error with FAKE_NPM_OFFLINE), whose `gitHead` +// is FAKE_NPM_GITHEAD, and `npm publish`, which fails for the workspace in +// FAKE_NPM_FAIL_PUBLISH. Every other command succeeds without doing anything. const fakeNpm = `const { appendFileSync } = require("node:fs"); const args = process.argv.slice(2); appendFileSync(process.env.FAKE_NPM_LOG, JSON.stringify(args) + "\\n"); @@ -27,12 +28,18 @@ if (args[0] === "view") { } const spec = args[1]; const published = (process.env.FAKE_NPM_PUBLISHED ?? "").split(","); - if (published.includes(spec)) { - process.stdout.write(JSON.stringify(spec.slice(spec.lastIndexOf("@") + 1))); - } else { + if (!published.includes(spec)) { process.stdout.write(JSON.stringify({ error: { code: "E404" } })); process.exit(1); + } else if (args[2] === "gitHead") { + const head = process.env.FAKE_NPM_GITHEAD; + process.stdout.write(head ? JSON.stringify(head) : ""); + } else { + process.stdout.write(JSON.stringify(spec.slice(spec.lastIndexOf("@") + 1))); } +} else if (args[0] === "publish" && args[2] === process.env.FAKE_NPM_FAIL_PUBLISH) { + process.stderr.write("npm error code E500\\n"); + process.exit(1); } `; @@ -70,6 +77,37 @@ describe("scripts/release.mjs", () => { }, }); + // A repository whose HEAD is the tagged release (without the hooks and the + // signing of the user) + const git = (...args) => { + const { status, stdout, stderr } = spawnSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "-c", + "tag.gpgsign=false", + "-c", + "init.defaultBranch=main", + ...args, + ], + { cwd: root, encoding: "utf8" }, + ); + equal(status, 0, stderr); + return stdout.trim(); + }; + const tagRelease = () => { + git("init", "-q"); + git("add", "-A"); + git("commit", "-q", "--no-verify", "-m", "release"); + git("tag", "v1.0.0"); + return git("rev-parse", "HEAD"); + }; + const npmCalls = (command) => { const log = join(root, "npm.log"); return existsSync(log) @@ -198,8 +236,23 @@ describe("scripts/release.mjs", () => { deepEqual(npmCalls("publish"), []); }); - it("publish --skip-existing skips published workspaces", () => { - const env = { FAKE_NPM_PUBLISHED: "@fixture/a@1.0.0" }; + it("publish stops at the first workspace that fails", () => { + const env = { FAKE_NPM_FAIL_PUBLISH: "packages/b" }; + const { status, stderr } = release(["publish"], env); + equal(status, 1); + match(stderr, /E500/u); + deepEqual(npmCalls("publish"), [ + ["publish", "--workspace", "packages/a"], + ["publish", "--workspace", "packages/b"], + ]); + }); + + it("publish --skip-existing publishes the rest of the tagged release", () => { + const head = tagRelease(); + const env = { + FAKE_NPM_PUBLISHED: "@fixture/a@1.0.0", + FAKE_NPM_GITHEAD: head, + }; const { status } = release(["publish", "--skip-existing"], env); equal(status, 0); deepEqual(npmCalls("publish"), [ @@ -208,6 +261,36 @@ describe("scripts/release.mjs", () => { ]); }); + it("publish --skip-existing refuses packages of another commit", () => { + tagRelease(); + for (const head of ["0".repeat(40), ""]) { + const env = { + FAKE_NPM_PUBLISHED: "@fixture/a@1.0.0", + FAKE_NPM_GITHEAD: head, + }; + const { status, stderr } = release(["publish", "--skip-existing"], env); + equal(status, 1); + match(stderr, /@fixture\/a@1\.0\.0 was published from/u); + } + deepEqual(npmCalls("publish"), []); + }); + + it("publish --skip-existing publishes from the commit of the tag only", () => { + tagRelease(); + writeFileSync(join(root, "later"), ""); + git("add", "-A"); + git("commit", "-q", "--no-verify", "-m", "later"); + const { status, stderr } = release(["publish", "--skip-existing"]); + equal(status, 1); + match(stderr, /is not the commit of v1\.0\.0/u); + deepEqual(npmCalls("publish"), []); + + git("tag", "-d", "v1.0.0"); + const untagged = release(["publish", "--skip-existing"]); + equal(untagged.status, 1); + deepEqual(npmCalls("publish"), []); + }); + it("fails on an unknown command", () => { const { status, stderr } = release(["deploy"]); equal(status, 1); From be33682b34237209580b1a4a7728747bafcd491a Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 04:19:53 +0300 Subject: [PATCH 103/675] ci: pin the actions by their commit The actions were referenced by their major tags, which their owners can move to any commit. They are now pinned to the commit of their current release (with the version in a comment), which Dependabot keeps up to date. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0d36c8c..270d5511 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,11 +30,11 @@ jobs: timeout-minutes: 15 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 check-latest: true @@ -72,7 +72,7 @@ jobs: - name: Upload docs if: github.event_name == 'push' - uses: actions/upload-pages-artifact@v5 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: docs @@ -89,9 +89,9 @@ jobs: node-version: [24, 26] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ matrix.node-version }} check-latest: true @@ -105,7 +105,7 @@ jobs: - name: Coveralls if: matrix.node-version == 24 - uses: coverallsapp/github-action@v2 + uses: coverallsapp/github-action@8d6379e14d29928660c4ba802d8e85393440b329 # v2.3.8 with: github-token: ${{ secrets.GITHUB_TOKEN }} file: coverage/lcov.info @@ -119,9 +119,9 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 check-latest: true @@ -155,11 +155,11 @@ jobs: HUSKY: 0 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 check-latest: true @@ -200,12 +200,12 @@ jobs: VERSION: ${{ inputs.version }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: v${{ inputs.version }} fetch-depth: 0 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 check-latest: true @@ -254,4 +254,4 @@ jobs: steps: - name: Deploy id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 From 2b0dda54beae0f8098a66b87cce946fbd7ef4cf0 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 04:20:10 +0300 Subject: [PATCH 104/675] build: run lint-staged without installing it in the pre-commit hook `npx lint-staged` downloads and runs `lint-staged` from the registry when it is not installed (e.g. before `npm install`), without a prompt when there is no terminal. The hook now runs the installed one only (`npx --no`), as the commit-msg hook already does for commitlint. Co-Authored-By: Claude Opus 5.5 --- .husky/pre-commit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 2312dc58..1524ad68 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -npx lint-staged +npx --no -- lint-staged From f79e6412750f3d40f07a924560d30ac8cf3abfe9 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 04:21:16 +0300 Subject: [PATCH 105/675] ci: check the files that the packages publish `npm run pack:check` only checked that `npm pack` succeeds: a module that is not built, or a file that should not be published (tests, sources, secrets), went unnoticed. It now compares the files of every tarball with `scripts/pack-files.json` (updated with `--update`), and fails on any missing or unexpected file. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- package.json | 2 +- scripts/pack-check.mjs | 54 +++++++++++++++++++++ scripts/pack-check.test.mjs | 93 +++++++++++++++++++++++++++++++++++++ scripts/pack-files.json | 79 +++++++++++++++++++++++++++++++ 5 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 scripts/pack-check.mjs create mode 100644 scripts/pack-check.test.mjs create mode 100644 scripts/pack-files.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 270d5511..771e9ba3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,7 @@ jobs: - name: Tooling tests (release script, test runner) run: npm run test:tooling - - name: Pack (test) + - name: Packed files (scripts/pack-files.json) run: npm run pack:check - name: Consumer check (packed packages, TypeScript and ESM) diff --git a/package.json b/package.json index 95bfdd25..66683492 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "docs:build": "typedoc", "eslint": "eslint .", "eslint:fix": "eslint . --fix", - "pack:check": "npm pack --dry-run --workspaces", + "pack:check": "node scripts/pack-check.mjs", "prepare": "husky && npm run build", "prettier": "prettier -c .", "prettier:write": "prettier -c -w .", diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs new file mode 100644 index 00000000..4e281b12 --- /dev/null +++ b/scripts/pack-check.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +// Checks the files that `npm pack` puts in the tarball of every workspace against +// `scripts/pack-files.json`: a file that is missing (e.g. a module not built) or that +// should not be published (e.g. tests, sources or secrets) fails the check. +// The workspaces must be built before (`npm run build`): their `prepack` does not run. +// +// node scripts/pack-check.mjs check +// node scripts/pack-check.mjs --update write the current files as the expected ones +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { npm } from "./npm.mjs"; + +const root = join(import.meta.dirname, ".."); +const expectedFile = join(root, "scripts", "pack-files.json"); + +const packed = JSON.parse( + npm(["pack", "--dry-run", "--json", "--workspaces", "--ignore-scripts"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }), +); +const actual = Object.fromEntries( + packed.map(({ name, files }) => [name, files.map(({ path }) => path).sort()]), +); + +if (process.argv.includes("--update")) { + writeFileSync(expectedFile, `${JSON.stringify(actual, null, 2)}\n`); + process.stderr.write(`[pack] ${expectedFile} updated\n`); +} else { + const expected = JSON.parse(readFileSync(expectedFile, "utf8")); + const errors = []; + for (const name of new Set([ + ...Object.keys(expected), + ...Object.keys(actual), + ])) { + const want = new Set(expected[name] ?? []); + const have = new Set(actual[name] ?? []); + for (const file of have.difference(want)) { + errors.push(`${name}: unexpected ${file}`); + } + for (const file of want.difference(have)) { + errors.push(`${name}: missing ${file}`); + } + } + if (errors.length > 0) { + throw new Error( + `The packed files differ from scripts/pack-files.json (run \`node scripts/pack-check.mjs --update\` if this is expected):\n ${errors.join("\n ")}`, + ); + } + process.stderr.write( + `[pack] ${Object.keys(actual).length} workspaces with the expected files\n`, + ); +} diff --git a/scripts/pack-check.test.mjs b/scripts/pack-check.test.mjs new file mode 100644 index 00000000..4f3b6423 --- /dev/null +++ b/scripts/pack-check.test.mjs @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, it } from "node:test"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { deepEqual, equal, match } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; + +// A fake npm CLI (run as `npm_execpath`) whose `npm pack --json` prints FAKE_NPM_PACK +const fakeNpm = `process.stdout.write(process.env.FAKE_NPM_PACK);\n`; + +const packed = (files) => + JSON.stringify( + Object.entries(files).map(([name, paths]) => ({ + name, + files: paths.map((path) => ({ path })), + })), + ); + +describe("scripts/pack-check.mjs", () => { + let root = ""; + + const check = (files, ...args) => + spawnSync( + process.execPath, + [join(root, "scripts/pack-check.mjs"), ...args], + { + encoding: "utf8", + env: { + ...process.env, + npm_execpath: join(root, "npm.cjs"), + FAKE_NPM_PACK: packed(files), + }, + }, + ); + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "binden-pack-check-")); + mkdirSync(join(root, "scripts")); + for (const script of ["pack-check.mjs", "npm.mjs"]) { + copyFileSync( + join(import.meta.dirname, script), + join(root, "scripts", script), + ); + } + writeFileSync(join(root, "npm.cjs"), fakeNpm); + writeFileSync( + join(root, "scripts/pack-files.json"), + JSON.stringify({ a: ["LICENSE", "dist/index.js"] }), + ); + }); + + afterEach(() => { + rmSync(root, { force: true, recursive: true }); + }); + + it("passes with the expected files", () => { + const { status, stderr } = check({ a: ["dist/index.js", "LICENSE"] }); + equal(status, 0); + match(stderr, /1 workspaces with the expected files/u); + }); + + it("fails with an unexpected or a missing file", () => { + const { status, stderr } = check({ a: ["LICENSE", "dist/test/a.test.js"] }); + equal(status, 1); + match(stderr, /a: unexpected dist\/test\/a\.test\.js/u); + match(stderr, /a: missing dist\/index\.js/u); + }); + + it("fails with an unexpected workspace", () => { + const { status, stderr } = check({ + a: ["LICENSE", "dist/index.js"], + b: ["x"], + }); + equal(status, 1); + match(stderr, /b: unexpected x/u); + }); + + it("--update writes the current files", () => { + const { status } = check({ a: ["b", "a"] }, "--update"); + equal(status, 0); + deepEqual( + JSON.parse(readFileSync(join(root, "scripts/pack-files.json"), "utf8")), + { a: ["a", "b"] }, + ); + }); +}); diff --git a/scripts/pack-files.json b/scripts/pack-files.json new file mode 100644 index 00000000..02c3738c --- /dev/null +++ b/scripts/pack-files.json @@ -0,0 +1,79 @@ +{ + "@binden/logger": [ + "LICENSE", + "README.md", + "dist/index.d.ts", + "dist/index.js", + "package.json" + ], + "binden": [ + "LICENSE", + "README.md", + "dist/index.d.ts", + "dist/index.js", + "dist/src/binden.d.ts", + "dist/src/binden.js", + "dist/src/context.d.ts", + "dist/src/context.js", + "dist/src/error.d.ts", + "dist/src/error.js", + "dist/src/headers/accept-encoding.d.ts", + "dist/src/headers/accept-encoding.js", + "dist/src/headers/authorization.d.ts", + "dist/src/headers/authorization.js", + "dist/src/headers/content-encoding.d.ts", + "dist/src/headers/content-encoding.js", + "dist/src/headers/content-range.d.ts", + "dist/src/headers/content-range.js", + "dist/src/headers/content-type.d.ts", + "dist/src/headers/content-type.js", + "dist/src/headers/cookie.d.ts", + "dist/src/headers/cookie.js", + "dist/src/headers/forwarded.d.ts", + "dist/src/headers/forwarded.js", + "dist/src/headers/http-date.d.ts", + "dist/src/headers/http-date.js", + "dist/src/headers/if-modified-since.d.ts", + "dist/src/headers/if-modified-since.js", + "dist/src/headers/list.d.ts", + "dist/src/headers/list.js", + "dist/src/headers/range.d.ts", + "dist/src/headers/range.js", + "dist/src/logger.d.ts", + "dist/src/logger.js", + "dist/src/middleware.d.ts", + "dist/src/middleware.js", + "dist/src/property.d.ts", + "dist/src/property.js", + "dist/src/request.d.ts", + "dist/src/request.js", + "dist/src/response.d.ts", + "dist/src/response.js", + "dist/src/router.d.ts", + "dist/src/router.js", + "dist/src/status.d.ts", + "dist/src/status.js", + "package.json" + ], + "@binden/body": [ + "LICENSE", + "README.md", + "dist/index.d.ts", + "dist/index.js", + "package.json" + ], + "@binden/compression": [ + "LICENSE", + "README.md", + "dist/index.d.ts", + "dist/index.js", + "package.json" + ], + "@binden/multipart": [ + "LICENSE", + "README.md", + "dist/index.d.ts", + "dist/index.js", + "package.json" + ] +} From 3bb47e41ee38a348929c8722e9cb37972218aaf7 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 04:22:02 +0300 Subject: [PATCH 106/675] build: compile the packages for ES2024 The packages were compiled for `esnext`, whose meaning changes with every version of TypeScript (and the features it allows with it). They are now compiled for ES2024, which the oldest supported Node.js (24) implements, so that a later feature of the language cannot slip in without a deliberate change. The output only differs in how a class refers to itself in its static members (through an alias), with the same behaviour. Co-Authored-By: Claude Opus 5.5 --- tsconfig.base.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.base.json b/tsconfig.base.json index 0bb7f06b..c4af72e3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "esnext", + "target": "es2024", "experimentalDecorators": true, "useDefineForClassFields": true, "module": "nodenext", From bd7af1accb87cc91785138db4582ac466563236f Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 04:23:59 +0300 Subject: [PATCH 107/675] ci: test the oldest supported Node.js The packages require Node.js 24.14.1, but CI only tested the latest version of each line, so that a use of a later API went unnoticed. The `test` job now runs on 24.14.1 as well. The tooling of the repository required 24.15.0 without needing it (the tests and the tooling tests pass on 24.14.1, with its npm 11.11.0): it now requires the same version as the packages. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 ++- README.md | 2 +- package-lock.json | 2 +- package.json | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 771e9ba3..f7fd8f59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,7 +86,8 @@ jobs: strategy: fail-fast: false matrix: - node-version: [24, 26] + # The oldest supported version (the `engines` of the packages), and the latest of each line + node-version: [24.14.1, 24, 26] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/README.md b/README.md index 5a609b42..5901d209 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ API documentation: ## Development -Requires Node.js `>=24.15.0` and npm `>=11.11.0`. +Requires Node.js `>=24.14.1` and npm `>=11.11.0`, as the packages do. ```bash npm install # installs all workspaces and builds them (`prepare`) diff --git a/package-lock.json b/package-lock.json index 09307e8d..55ac653c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "typescript-eslint": "^8.71.2" }, "engines": { - "node": ">=24.15.0", + "node": ">=24.14.1", "npm": ">=11.11.0" } }, diff --git a/package.json b/package.json index 66683492..0f4e2c39 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "packages/multipart" ], "engines": { - "node": ">=24.15.0", + "node": ">=24.14.1", "npm": ">=11.11.0" }, "scripts": { From 8a23e12feeaa2a4737a8d93e7f430ae6f1080de3 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 04:25:38 +0300 Subject: [PATCH 108/675] ci: rehearse the release on every pull request Only a push to `main` ran semantic-release, so that a wrong configuration, a commit that does not give the expected version, or a failure of `scripts/release.mjs version` showed up in the release itself. `npm run release:rehearsal` (run by the `lint` job and `npm run ci:all`) now runs semantic-release in dry-run mode, with its analyzer and its notes only, in a clone whose remote is a local mirror, prints the next version and its release notes, and checks it with `scripts/release.mjs` (`verify`, then `version` in the clone). Nothing is tagged or published. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 ++ README.md | 1 + package.json | 3 +- scripts/release-rehearsal.mjs | 100 ++++++++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 scripts/release-rehearsal.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7fd8f59..05a091e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,11 @@ jobs: - name: Consumer check (packed packages, TypeScript and ESM) run: npm run consumer:check + # The next version and its release notes, as semantic-release computes them (nothing is + # tagged or published) + - name: Release rehearsal + run: npm run release:rehearsal + - name: Docs run: npm run docs:build diff --git a/README.md b/README.md index 5901d209..3f9b6415 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ npm run coverage # tests with c8 coverage (coverage/lcov.info) npm run eslint # type-aware ESLint for the whole repository npm run docs:build # TypeDoc for all packages (docs/) npm run consumer:check # the packed packages and the README examples in a fresh project +npm run release:rehearsal # the next version and its release notes, without releasing npm run ci:all # everything CI runs ``` diff --git a/package.json b/package.json index 0f4e2c39..a2231ce6 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ }, "scripts": { "build": "npm run build --workspaces", - "ci:all": "npm ci && npm run release:check && npm run prettier && npm run commitlint && npm run eslint && npm run test:tooling && npm run coverage && npm run pack:check && npm run consumer:check && npm run docs:build", + "ci:all": "npm ci && npm run release:check && npm run prettier && npm run commitlint && npm run eslint && npm run test:tooling && npm run coverage && npm run pack:check && npm run consumer:check && npm run release:rehearsal && npm run docs:build", "commitlint": "commitlint --verbose --from=origin/main", "consumer:check": "node scripts/consumer-check.mjs", "coverage": "c8 npm test --", @@ -28,6 +28,7 @@ "prettier": "prettier -c .", "prettier:write": "prettier -c -w .", "release:check": "node scripts/release.mjs check", + "release:rehearsal": "node scripts/release-rehearsal.mjs", "test": "npm test --workspaces --if-present --", "test:tooling": "node --test scripts/*.test.mjs" }, diff --git a/scripts/release-rehearsal.mjs b/scripts/release-rehearsal.mjs new file mode 100644 index 00000000..75835f59 --- /dev/null +++ b/scripts/release-rehearsal.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +// Rehearses the release of the current commit, without tagging or publishing +// anything: semantic-release (with its analyzer and its notes only) computes the +// next version in a clone whose remote is a local mirror, then +// `scripts/release.mjs` checks that no workspace is published at it yet, and +// sets it in every workspace of the clone. +import { execFileSync, spawnSync } from "node:child_process"; +import { + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { tmpdir } from "node:os"; + +const root = join(import.meta.dirname, ".."); +const dir = mkdtempSync(join(tmpdir(), "binden-rehearsal-")); +const mirror = join(dir, "mirror.git"); +const work = join(dir, "work"); +const plugins = new Set([ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", +]); + +function log(message) { + process.stderr.write(`[rehearsal] ${message}\n`); +} + +function git(args, cwd = work) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }).trim(); +} + +function node(args) { + execFileSync(process.execPath, args, { + cwd: work, + stdio: ["ignore", process.stderr, "inherit"], + }); +} + +try { + const head = git(["rev-parse", "HEAD"], root); + git(["clone", "--quiet", "--bare", root, mirror], dir); + git(["clone", "--quiet", mirror, work], dir); + git(["checkout", "--quiet", "-B", "rehearsal", head]); + git(["push", "--quiet", "origin", "rehearsal"]); + + const configFile = join(work, ".releaserc.json"); + const config = JSON.parse(readFileSync(configFile, "utf8")); + config.branches = ["rehearsal"]; + config.plugins = config.plugins.filter((plugin) => + plugins.has(Array.isArray(plugin) ? plugin[0] : plugin), + ); + writeFileSync(configFile, JSON.stringify(config)); + symlinkSync( + join(root, "node_modules"), + join(work, "node_modules"), + "junction", + ); + + // Not the CI of the repository, without its tokens + const hidden = new Set(["CI", "GITHUB_ACTIONS", "GITHUB_TOKEN", "GH_TOKEN"]); + const env = Object.fromEntries( + Object.entries(process.env).filter(([name]) => !hidden.has(name)), + ); + const { status, stdout, stderr } = spawnSync( + process.execPath, + [ + join(root, "node_modules/semantic-release/bin/semantic-release.js"), + "--dry-run", + "--no-ci", + "--repository-url", + pathToFileURL(mirror).href, + ], + { cwd: work, encoding: "utf8", env }, + ); + process.stderr.write(stdout); + if (status !== 0) { + process.stderr.write(stderr); + throw new Error(`semantic-release failed (${status})`); + } + + const version = /The next release version is (?\S+)/u.exec(stdout) + ?.groups?.version; + if (typeof version === "undefined") { + log("no release is due"); + } else { + node(["scripts/release.mjs", "verify", version]); + node(["scripts/release.mjs", "version", version]); + log(`the next release is ${version}`); + } +} finally { + rmSync(dir, { force: true, recursive: true }); +} From 36d0009c07dc249fca99c0df4a35404612ef28a8 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 04:27:06 +0300 Subject: [PATCH 109/675] test: reuse the connection after an error reply in binden The keep-alive tests now also send a request after the error reply to a middleware that had set a `Content-Length` for its body, on the same connection: the error reply must be framed by its own body, or the next response is read as the rest of it. Co-Authored-By: Claude Opus 5.5 --- packages/binden/test/keep-alive.test.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/binden/test/keep-alive.test.ts b/packages/binden/test/keep-alive.test.ts index 8a40812b..99dfe3c3 100644 --- a/packages/binden/test/keep-alive.test.ts +++ b/packages/binden/test/keep-alive.test.ts @@ -6,7 +6,7 @@ import type { AddressInfo } from "node:net"; import { Readable } from "node:stream"; import { afterEach, beforeEach, suite, test } from "node:test"; -import { Binden, type Context } from "../index.js"; +import { Binden, BindenError, type Context } from "../index.js"; /** The status, the body and whether the connection was reused */ interface IExchange { @@ -34,6 +34,11 @@ suite("Keep-alive", () => { .setHeader("Content-Length", 99) .response.sendFile(import.meta.filename), "/head": (context) => context.send("abc"), + "/error": (context) => { + // The error reply replaces the body that this length was set for + context.setHeader("Content-Length", 99); + throw new BindenError(400, { expose: true, message: "error" }); + }, "/short": (context) => context.setHeader("Content-Length", 10).send(Readable.from(["abc"])), }; @@ -105,6 +110,19 @@ suite("Keep-alive", () => { ["HEAD with a body", "/head", "HEAD", {}, 200], ]; + test("an error reply after a `Content-Length`, then another request", async () => { + deepStrictEqual(await exchange("/error"), { + status: 400, + body: "error", + reused: true, + }); + deepStrictEqual(await exchange("/ok"), { + status: 200, + body: "ok", + reused: true, + }); + }); + test("a body shorter than its `Content-Length` closes the connection", async () => { // The client knows that the body is truncated await rejects(exchange("/short"), { code: "ECONNRESET" }); From 4302ad91cd86516c898a5ca4ff03ff9008e3829b Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 23:12:59 +0300 Subject: [PATCH 110/675] feat: share the state of a request between its contexts in binden Every context of a request (including a new context of the same request that a middleware returns) now shares one exchange, which owns: - `context.signal`, aborted once with a `BindenAbortError` whose reason is `client_closed` (the response is closed before it finishes), `deadline` or `shutdown`; - `context.setTimeout(ms)`, which (re)starts the deadline of the request (`0` removes it); - `context.onCleanup(fn)`, whose functions run once the response is closed, one at a time, the last registered first, with their failures logged; - `context.state`, `context.params`, `context.mount_path` and `done`. `@binden/logger` exports the `ILogger` interface, which the logger of an application and of a context may implement instead of extending `Logger`. Co-Authored-By: Claude Opus 5.5 --- packages/binden/index.ts | 1 + packages/binden/src/binden.ts | 6 +- packages/binden/src/context.ts | 78 +++++- packages/binden/src/exchange.ts | 235 +++++++++++++++++ packages/binden/test/exchange.test.ts | 366 ++++++++++++++++++++++++++ packages/logger/index.ts | 13 +- 6 files changed, 683 insertions(+), 16 deletions(-) create mode 100644 packages/binden/src/exchange.ts create mode 100644 packages/binden/test/exchange.test.ts diff --git a/packages/binden/index.ts b/packages/binden/index.ts index dbf2b23d..fd638c9a 100644 --- a/packages/binden/index.ts +++ b/packages/binden/index.ts @@ -9,6 +9,7 @@ export * from "./src/headers/if-modified-since.js"; export * from "./src/headers/range.js"; export * from "./src/context.js"; export * from "./src/error.js"; +export { BindenAbortError, type IAbortReason } from "./src/exchange.js"; export * from "./src/binden.js"; export * from "./src/middleware.js"; export * from "./src/request.js"; diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index 9c9e9e39..7405342e 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -1,7 +1,7 @@ import http from "node:http"; import https from "node:https"; import { isDeepStrictEqual } from "node:util"; -import type { Logger } from "@binden/logger"; +import type { ILogger } from "@binden/logger"; import { Context } from "./context.js"; import { BindenError } from "./error.js"; import defaultLogger from "./logger.js"; @@ -26,7 +26,7 @@ export interface IBindenOptions { /** Replaces the default reply to an error, unless it does not reply itself */ error_handler?: (context: Context, error: unknown) => Promise | void; /** The logger of the contexts (the default one is set up by `BINDEN_LOG_LEVEL`) */ - logger?: Logger; + logger?: ILogger; /** * The proxies whose `Forwarded` elements `request.protocol` trusts: none * (`false`, the default), all of them (`true`) or the given number of the @@ -40,7 +40,7 @@ export class Binden { readonly #auto_head: boolean; readonly #error_handler: ((context: Context, error: unknown) => Promise | void) | null; - readonly #logger: Logger; + readonly #logger: ILogger; readonly #Request: typeof BindenRequest; public constructor({ diff --git a/packages/binden/src/context.ts b/packages/binden/src/context.ts index 40465568..21dc7ebb 100644 --- a/packages/binden/src/context.ts +++ b/packages/binden/src/context.ts @@ -1,5 +1,6 @@ -import { Logger } from "@binden/logger"; +import type { ILogger } from "@binden/logger"; import { BindenError, type IBindenErrorOptions } from "./error.js"; +import { Exchange } from "./exchange.js"; import defaultLogger from "./logger.js"; import type { Readable } from "node:stream"; @@ -13,25 +14,39 @@ export interface IBaseContext { export interface IContextOptions extends IBaseContext { /** The logger whose child (with the `trace_id` of the context) is `context.log` */ - readonly logger?: Logger; + readonly logger?: ILogger | undefined; + /** + * The state of the request: used by the first context of the request only + * (the others share it) + */ + readonly state?: object | undefined; } -export class Context implements IBaseContext { +export type IParams = Record; + +export class Context< + State extends object = Record, + Params extends IParams = IParams, +> implements IBaseContext { readonly #request: BindenRequest; readonly #response: BindenResponse; - readonly #parent: Logger; - #logger?: Logger; - #done: boolean; + readonly #parent: ILogger; + readonly #exchange: Exchange; + #logger?: ILogger; public constructor({ request, response, logger = defaultLogger, + state, }: IContextOptions) { this.#request = request; this.#response = response; - this.#done = false; this.#parent = logger; + this.#exchange = Exchange.for(request, response, { + state, + report: (message, error) => this.log.error(message, { error }), + }); } /** @@ -40,7 +55,7 @@ export class Context implements IBaseContext { * whose `child()` throws) is used itself: logging never prevents the context * from being created. */ - public get log(): Logger { + public get log(): ILogger { if (!this.#logger) { try { this.#logger = this.#parent.child({ trace_id: this.id }); @@ -73,14 +88,53 @@ export class Context implements IBaseContext { return this.request.id; } + /** Set once the request is handled: shared by every context of the request */ public get done(): boolean { - return this.#done; + return this.#exchange.done; } public set done(done: unknown) { - if (!this.#done && Boolean(done)) { - this.#done = true; - } + this.#exchange.done = Boolean(done); + } + + /** + * Aborted once the work for the request should stop: the client has gone + * away (`client_closed`), the deadline has passed (`deadline`) or the server + * is shutting down (`shutdown`); its reason is a `BindenAbortError` + */ + public get signal(): AbortSignal { + return this.#exchange.signal; + } + + /** The state of the request, shared by every context of the request */ + public get state(): State { + return this.#exchange.state as State; + } + + /** The parameters of the matched route */ + public get params(): Readonly { + return this.#exchange.params as Readonly; + } + + /** The matched mount prefixes, with their values (`""` at the root) */ + public get mount_path(): string { + return this.#exchange.mount_path; + } + + /** + * (Re)starts the deadline of the request from now (`0` removes it): once it + * has passed, `signal` is aborted with the `deadline` reason + */ + public setTimeout(ms: number): void { + this.#exchange.setTimeout(ms); + } + + /** + * Runs `cleanup` once the response is closed (finished, or its connection + * closed), after the cleanups registered later; a failure is logged + */ + public onCleanup(cleanup: () => unknown): void { + this.#exchange.onCleanup(cleanup); } public get url(): URL { diff --git a/packages/binden/src/exchange.ts b/packages/binden/src/exchange.ts new file mode 100644 index 00000000..c605eee4 --- /dev/null +++ b/packages/binden/src/exchange.ts @@ -0,0 +1,235 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; + +/** Why the work of a request has been abandoned */ +export type IAbortReason = "client_closed" | "deadline" | "shutdown"; + +/** The reason of `context.signal` once the request is aborted */ +export class BindenAbortError extends Error { + readonly #reason: IAbortReason; + + public constructor(reason: IAbortReason) { + super(`The request has been aborted (${reason})`); + this.name = "BindenAbortError"; + this.#reason = reason; + } + + public get reason(): IAbortReason { + return this.#reason; + } +} + +/** Reports a failure that nothing else can handle (e.g. a cleanup that throws) */ +export type IExchangeReport = (message: string, error: unknown) => void; + +export interface IExchangeOptions { + /** The state of the request, created once for the exchange */ + readonly state?: object | undefined; + readonly report?: IExchangeReport | undefined; +} + +type IPhase = "finalizing" | "finished" | "running"; + +/** The greatest delay of a Node.js timer */ +const MAX_DELAY = 2 ** 31 - 1; + +const exchanges = new WeakMap(); + +/** + * The state of one request and its response, shared by every context created + * for them (e.g. a middleware that returns a new context of the same request): + * its abort signal, deadline, state, `done` flag and cleanup. + */ +export class Exchange { + readonly #response: ServerResponse; + readonly #controller: AbortController; + readonly #state: object; + readonly #cleanups: (() => unknown)[]; + readonly #finished: Promise; + readonly #report: IExchangeReport; + #phase: IPhase; + #done: boolean; + #timer: NodeJS.Timeout | null; + #params: Readonly>; + #mount_path: string; + #mount_template: string; + + private constructor( + response: ServerResponse, + { state = {}, report }: IExchangeOptions, + ) { + this.#response = response; + this.#controller = new AbortController(); + this.#state = state; + this.#cleanups = []; + this.#report = report ?? Exchange.#ignore; + this.#phase = "running"; + this.#done = false; + this.#timer = null; + this.#params = Object.freeze({}); + this.#mount_path = ""; + this.#mount_template = ""; + + // `close` follows `finish`, unless the connection is closed before the + // response is complete + this.#finished = new Promise((resolve) => { + response.once("close", () => { + if (!response.writableFinished) { + this.abort("client_closed"); + } + resolve(this.#finish()); + }); + }); + } + + /** + * The exchange of a request, created on first use (the options apply only + * then). A response other than the one of the exchange is refused. + */ + public static for( + request: IncomingMessage, + response: ServerResponse, + options: IExchangeOptions = {}, + ): Exchange { + const found = exchanges.get(request); + if (typeof found === "undefined") { + const exchange = new Exchange(response, options); + exchanges.set(request, exchange); + return exchange; + } else if (found.#response !== response) { + throw new TypeError("The response is not the one of the request"); + } + return found; + } + + public get signal(): AbortSignal { + return this.#controller.signal; + } + + public get state(): object { + return this.#state; + } + + public get done(): boolean { + return this.#done; + } + + /** `done` cannot be unset */ + public set done(done: boolean) { + this.#done ||= done; + } + + /** The parameters of the current match (set by the routing) */ + public get params(): Readonly> { + return this.#params; + } + + public set params(params: Readonly>) { + this.#params = Object.freeze({ ...params }); + } + + /** The matched mount prefixes, with their values (`""` at the root) */ + public get mount_path(): string { + return this.#mount_path; + } + + public set mount_path(path: string) { + this.#mount_path = path; + } + + /** The matched mount prefixes, as registered (e.g. `/users/:id`) */ + public get mount_template(): string { + return this.#mount_template; + } + + public set mount_template(template: string) { + this.#mount_template = template; + } + + public get phase(): IPhase { + return this.#phase; + } + + /** Settles once the response is closed and every cleanup has run */ + public get finished(): Promise { + return this.#finished; + } + + /** Aborts the signal, once: `false` when it has already been aborted */ + public abort(reason: IAbortReason): boolean { + if (this.#controller.signal.aborted) { + return false; + } + this.#clearTimer(); + this.#controller.abort(new BindenAbortError(reason)); + return true; + } + + /** + * (Re)starts the deadline of the request from now: the signal is aborted + * (`deadline`) once `ms` milliseconds have passed. `0` removes the deadline. + */ + public setTimeout(ms: number): void { + if (!Number.isInteger(ms)) { + throw new TypeError("The timeout is not an integer"); + } else if (ms < 0 || ms > MAX_DELAY) { + throw new RangeError(`The timeout is not between 0 and ${MAX_DELAY}`); + } else if (this.#phase !== "running") { + throw new TypeError("The request has been completed"); + } + this.#clearTimer(); + if (ms > 0 && !this.#controller.signal.aborted) { + this.#timer = setTimeout(() => { + this.#timer = null; + this.abort("deadline"); + }, ms); + this.#timer.unref(); + } + } + + /** + * Registers a function to run once the response is closed (finished, or its + * connection closed): the functions run one at a time, the last registered + * first, and their failures are reported + */ + public onCleanup(cleanup: () => unknown): void { + if (typeof cleanup !== "function") { + throw new TypeError("The cleanup is not a function"); + } else if (this.#phase !== "running") { + throw new TypeError("The request has been completed"); + } + this.#cleanups.push(cleanup); + } + + async #finish(): Promise { + this.#phase = "finalizing"; + this.#clearTimer(); + for (let cleanup = this.#cleanups.pop(); cleanup;) { + try { + await cleanup(); + } catch (error: unknown) { + this.#safeReport("A cleanup of the request has failed", error); + } + cleanup = this.#cleanups.pop(); + } + this.#phase = "finished"; + } + + #clearTimer(): void { + if (this.#timer) { + clearTimeout(this.#timer); + this.#timer = null; + } + } + + #safeReport(message: string, error: unknown): void { + try { + this.#report(message, error); + } catch { + // Nothing else to report it to + } + } + + static #ignore(): void { + // No reporter + } +} diff --git a/packages/binden/test/exchange.test.ts b/packages/binden/test/exchange.test.ts new file mode 100644 index 00000000..e80d57b9 --- /dev/null +++ b/packages/binden/test/exchange.test.ts @@ -0,0 +1,366 @@ +/* eslint-disable init-declarations */ +import { deepStrictEqual, ok, rejects, throws } from "node:assert"; +import { once } from "node:events"; +import { type Server, createServer, get } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, suite, test } from "node:test"; + +import { + BindenAbortError, + BindenRequest, + BindenResponse, + Context, +} from "../index.js"; +import { Exchange } from "../src/exchange.js"; + +type IServer = Server; + +/** Resolves after `ms` milliseconds */ +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** A function that does nothing */ +function nothing(): void { + // Nothing to do +} +type IPair = [BindenRequest, BindenResponse]; + +/** A logger that keeps its error records */ +function recorder(): { + errors: [string, Record | undefined][]; + logger: Context["log"]; +} { + const errors: [string, Record | undefined][] = []; + const noop = (): void => { + // Not recorded + }; + const logger: Context["log"] = { + fatal: noop, + error: (message: string, extra?: Record): void => { + errors.push([message, extra]); + }, + warn: noop, + info: noop, + debug: noop, + trace: noop, + child: (): Context["log"] => logger, + }; + return { errors, logger }; +} + +suite("Exchange", () => { + let server: IServer; + let port = 0; + + /** The next request of the server, sent by `get()`, with its client request */ + const exchange = async (): Promise< + IPair & { client: ReturnType } + > => { + const pending = once(server, "request") as Promise; + const client = get({ port, path: "/" }, (response) => response.resume()); + client.on("error", () => { + // Aborted by the test + }); + const [request, response] = await pending; + return Object.assign([request, response] as IPair, { client }); + }; + + beforeEach(async () => { + server = createServer({ + IncomingMessage: BindenRequest, + ServerResponse: BindenResponse, + }); + server.listen(0); + await once(server, "listening"); + ({ port } = server.address() as AddressInfo); + }); + + afterEach(async () => { + server.closeAllConnections(); + server.close(); + await once(server, "close"); + }); + + test("the signal is aborted when the client goes away first", async () => { + const pair = await exchange(); + const context = new Context({ request: pair[0], response: pair[1] }); + ok(!context.signal.aborted); + + pair.client.destroy(); + await once(context.signal, "abort"); + + const { reason } = context.signal as { reason: unknown }; + ok(reason instanceof BindenAbortError); + deepStrictEqual(reason.reason, "client_closed"); + deepStrictEqual(reason.name, "BindenAbortError"); + }); + + test("the signal is not aborted by a complete response", async () => { + const [request, response] = await exchange(); + const context = new Context({ request, response }); + + response.end("ok"); + await once(response, "close"); + await Exchange.for(request, response).finished; + + ok(!context.signal.aborted); + deepStrictEqual(Exchange.for(request, response).phase, "finished"); + }); + + test("setTimeout() aborts the signal with `deadline`", async () => { + const [request, response] = await exchange(); + const context = new Context({ request, response }); + + context.setTimeout(10_000); + // Restarted: the shorter deadline wins + context.setTimeout(5); + await once(context.signal, "abort"); + const { reason } = context.signal as { reason: BindenAbortError }; + deepStrictEqual(reason.reason, "deadline"); + + // Already aborted: another deadline changes nothing + context.setTimeout(1); + response.end(); + await Exchange.for(request, response).finished; + deepStrictEqual(reason.reason, "deadline"); + }); + + test("setTimeout(0) removes the deadline", async () => { + const [request, response] = await exchange(); + const context = new Context({ request, response }); + + context.setTimeout(5); + context.setTimeout(0); + await sleep(30); + ok(!context.signal.aborted); + response.end(); + await Exchange.for(request, response).finished; + ok(!context.signal.aborted); + }); + + test("a deadline is cleared once the response is closed", async () => { + const [request, response] = await exchange(); + const context = new Context({ request, response }); + + context.setTimeout(20); + response.end(); + await Exchange.for(request, response).finished; + await sleep(40); + ok(!context.signal.aborted); + }); + + test("setTimeout() validates the delay and the state of the request", async () => { + const [request, response] = await exchange(); + const context = new Context({ request, response }); + + throws(() => { + context.setTimeout(1.5); + }, TypeError); + throws(() => { + context.setTimeout(Number.NaN); + }, TypeError); + throws(() => { + context.setTimeout(-1); + }, RangeError); + throws(() => { + context.setTimeout(2 ** 31); + }, RangeError); + context.setTimeout(2 ** 31 - 1); + context.setTimeout(0); + + response.end(); + await Exchange.for(request, response).finished; + throws(() => { + context.setTimeout(10); + }, TypeError); + }); + + test("the cleanups run once, one at a time, the last registered first", async () => { + const [request, response] = await exchange(); + const { errors, logger } = recorder(); + const context = new Context({ request, response, logger }); + const order: string[] = []; + + context.onCleanup(() => { + order.push("first"); + }); + context.onCleanup(async () => { + order.push("second:start"); + await sleep(10); + order.push("second:end"); + }); + context.onCleanup(() => { + throw new Error("A failing cleanup"); + }); + context.onCleanup(() => Promise.reject(new Error("A rejected cleanup"))); + + response.end(); + await Exchange.for(request, response).finished; + + deepStrictEqual(order, ["second:start", "second:end", "first"]); + deepStrictEqual( + errors.map(([message]) => message), + [ + "A cleanup of the request has failed", + "A cleanup of the request has failed", + ], + ); + deepStrictEqual( + errors.map(([, extra]) => (extra?.error as Error).message), + ["A rejected cleanup", "A failing cleanup"], + ); + + throws(() => { + context.onCleanup(nothing); + }, TypeError); + }); + + test("a failing logger does not stop the cleanups", async () => { + const [request, response] = await exchange(); + const { logger } = recorder(); + const failing: Context["log"] = { + ...logger, + error: (): never => { + throw new Error("The logger fails"); + }, + child: (): Context["log"] => failing, + }; + const context = new Context({ request, response, logger: failing }); + let ran = false; + + context.onCleanup(() => { + ran = true; + }); + context.onCleanup(() => { + throw new Error("A failing cleanup"); + }); + + response.end(); + await Exchange.for(request, response).finished; + ok(ran); + }); + + test("a cleanup cannot be registered while the cleanups run", async () => { + const [request, response] = await exchange(); + const context = new Context({ request, response }); + let late: unknown = null; + + context.onCleanup(() => { + try { + context.onCleanup(nothing); + } catch (error: unknown) { + late = error; + } + }); + + response.end(); + await Exchange.for(request, response).finished; + ok(late instanceof TypeError); + }); + + test("onCleanup() accepts functions only", async () => { + const [request, response] = await exchange(); + const context = new Context({ request, response }); + + throws(() => { + context.onCleanup(null as unknown as () => void); + }, TypeError); + response.end(); + }); + + test("the contexts of a request share its state", async () => { + const [request, response] = await exchange(); + const state = { user: "alice" }; + const first = new Context<{ user: string }>({ request, response, state }); + const second = new Context<{ user: string }>({ + request, + response, + state: { user: "bob" }, + }); + + deepStrictEqual(second.state, state); + ok(first.signal === second.signal); + + second.done = true; + ok(first.done); + first.done = false; + ok(second.done); + + deepStrictEqual(first.params, {}); + deepStrictEqual(first.mount_path, ""); + response.end(); + }); + + test("the default state is an empty object", async () => { + const [request, response] = await exchange(); + const context = new Context({ request, response }); + + deepStrictEqual(context.state, {}); + response.end(); + }); + + test("the exchange of a request refuses another response", async () => { + const [request, response] = await exchange(); + const [, other] = await exchange(); + deepStrictEqual(new Context({ request, response }).response, response); + + throws( + () => new Context({ request, response: other }), + new TypeError("The response is not the one of the request"), + ); + response.end(); + other.end(); + }); + + test("the routing state of the exchange", async () => { + const [request, response] = await exchange(); + const exchangeOf = Exchange.for(request, response); + const params = { id: "1" }; + + exchangeOf.params = params; + params.id = "2"; + deepStrictEqual(exchangeOf.params, { id: "1" }); + ok(Object.isFrozen(exchangeOf.params)); + + exchangeOf.mount_path = "/users/1"; + exchangeOf.mount_template = "/users/:id"; + deepStrictEqual(exchangeOf.mount_path, "/users/1"); + deepStrictEqual(exchangeOf.mount_template, "/users/:id"); + deepStrictEqual(exchangeOf.phase, "running"); + response.end(); + }); + + test("an exchange without a reporter ignores the failures of its cleanups", async () => { + const [request, response] = await exchange(); + const exchangeOf = Exchange.for(request, response); + let ran = false; + + exchangeOf.onCleanup(() => { + ran = true; + }); + exchangeOf.onCleanup(() => { + throw new Error("A failing cleanup"); + }); + response.end(); + await exchangeOf.finished; + ok(ran); + }); + + test("abort() aborts once", async () => { + const [request, response] = await exchange(); + const exchangeOf = Exchange.for(request, response); + + ok(exchangeOf.abort("shutdown")); + ok(!exchangeOf.abort("deadline")); + const { reason } = exchangeOf.signal as { reason: BindenAbortError }; + deepStrictEqual(reason.reason, "shutdown"); + response.end(); + await rejects( + Promise.reject(reason), + (error: unknown) => error instanceof BindenAbortError, + ); + }); +}); diff --git a/packages/logger/index.ts b/packages/logger/index.ts index 746f977a..1e805df2 100644 --- a/packages/logger/index.ts +++ b/packages/logger/index.ts @@ -18,7 +18,18 @@ const serializers = { response: stdSerializers.res, }; -export class Logger { +/** A logger that binden can use: the `Logger` of this package, or any object with these methods */ +export interface ILogger { + fatal(message: string, extra?: Record): unknown; + error(message: string, extra?: Record): unknown; + warn(message: string, extra?: Record): unknown; + info(message: string, extra?: Record): unknown; + debug(message: string, extra?: Record): unknown; + trace(message: string, extra?: Record): unknown; + child(bindings: Record): ILogger; +} + +export class Logger implements ILogger { readonly #logger: PinoLogger; public constructor({ logger, destination, ...options }: LoggerOptions = {}) { From fd36d8bb67f81b1f44bd6ed9cd68a4707ceaeced Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 23:33:51 +0300 Subject: [PATCH 111/675] feat!: validate the input of the header constructors in binden The constructors of Authorization, ContentEncoding, Forwarded and IfModifiedSince took anything, so a header built with them could be one that their own parsers reject (e.g. an Invalid Date sent as `If-Modified-Since: Invalid Date`, or a Forwarded value whose quote breaks the element). They now throw a TypeError for what their parsers cannot produce, and the parsers skip what the constructors refuse (e.g. control characters in a quoted string). Forwarded.prototype.toString() now sends every value that is not a token as a quoted string (RFC 7239, section 4), so that `for="[::1]:80"`, `host="example.com:8080"` and escaped quotes are parsed back as they were. BREAKING CHANGE: `new Authorization()` throws a TypeError for an unsupported scheme (or one not in its usual casing) and for credentials that a field value cannot carry, `new ContentEncoding()` for an unknown coding, `new Forwarded()` for a value with control characters, and `new IfModifiedSince()` for an Invalid Date or a year out of 0..9999. `Forwarded.prototype.toString()` quotes the values that are not tokens. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/headers/authorization.ts | 30 +++++++++-- .../binden/src/headers/content-encoding.ts | 4 ++ packages/binden/src/headers/forwarded.ts | 52 ++++++++++-------- .../binden/src/headers/if-modified-since.ts | 13 ++++- packages/binden/src/headers/list.ts | 18 +++++++ .../binden/test/headers/authorization.test.ts | 36 ++++++++++++- .../test/headers/content-encoding.test.ts | 14 ++++- .../binden/test/headers/forwarded.test.ts | 53 +++++++++++++++++-- .../test/headers/if-modified-since.test.ts | 26 ++++++--- 9 files changed, 205 insertions(+), 41 deletions(-) diff --git a/packages/binden/src/headers/authorization.ts b/packages/binden/src/headers/authorization.ts index 5c06231d..2244d5e8 100644 --- a/packages/binden/src/headers/authorization.ts +++ b/packages/binden/src/headers/authorization.ts @@ -1,3 +1,5 @@ +import { fieldValue } from "./list.js"; + export type IAuthenticationType = | "AWS4-HMAC-SHA256" | "Basic" @@ -19,7 +21,22 @@ export class Authorization implements IAuthorization { readonly #type: IAuthenticationType; readonly #credentials: string | null; + /** + * A supported scheme (in its usual casing) and its credentials: `null`, or + * what a field value can carry, without leading or trailing whitespace. + * Anything else throws a `TypeError`. + */ public constructor({ type, credentials = null }: IAuthorization) { + if (!Authorization.isValidType(type)) { + throw new TypeError(`Unsupported authentication scheme: ${String(type)}`); + } else if ( + credentials !== null && + (credentials === "" || + credentials !== credentials.trim() || + !fieldValue.test(credentials)) + ) { + throw new TypeError("Invalid credentials"); + } this.#type = type; this.#credentials = credentials; } @@ -52,10 +69,15 @@ export class Authorization implements IAuthorization { } const credentials = rest.trim(); - return new Authorization({ - type, - credentials: credentials === "" ? null : credentials, - }); + try { + return new Authorization({ + type, + credentials: credentials === "" ? null : credentials, + }); + } catch { + // E.g. control characters + return null; + } } /** The supported schemes by their lowercase names */ diff --git a/packages/binden/src/headers/content-encoding.ts b/packages/binden/src/headers/content-encoding.ts index 47c8f72a..cbc5af23 100644 --- a/packages/binden/src/headers/content-encoding.ts +++ b/packages/binden/src/headers/content-encoding.ts @@ -7,7 +7,11 @@ export interface IContentEncoding { export class ContentEncoding implements IContentEncoding { readonly #encoding: IEncodings; + /** A known content coding (in lowercase), or a `TypeError` */ public constructor({ encoding }: IContentEncoding) { + if (!ContentEncoding.#encodings.has(encoding)) { + throw new TypeError(`Unknown content coding: ${String(encoding)}`); + } this.#encoding = encoding; } diff --git a/packages/binden/src/headers/forwarded.ts b/packages/binden/src/headers/forwarded.ts index 6771e756..7dc1d72b 100644 --- a/packages/binden/src/headers/forwarded.ts +++ b/packages/binden/src/headers/forwarded.ts @@ -1,4 +1,4 @@ -import { split, unquote } from "./list.js"; +import { fieldValue, quote, split, unquote } from "./list.js"; export interface IForwarded { for: string; @@ -15,7 +15,19 @@ export class Forwarded implements IForwarded { readonly #secret?: string | null; readonly #proto?: string | null; + /** + * The parameters of an element: strings that a field value can carry (no + * control characters but tabs), sent as quoted strings when they are not + * tokens. Anything else throws a `TypeError`. + */ public constructor({ by, host, secret, proto, ..._ }: IForwarded) { + const parameters = { for: _.for, by, host, secret, proto }; + for (const [name, value] of Object.entries(parameters)) { + const optional = name !== "for" && (value ?? null) === null; + if (!optional && !(typeof value === "string" && fieldValue.test(value))) { + throw new TypeError(`Invalid \`${name}\` parameter`); + } + } this.#for = _.for; this.#by = by ?? null; this.#host = host ?? null; @@ -43,18 +55,13 @@ export class Forwarded implements IForwarded { return this.#proto ?? null; } + /** The element, with its values that are not tokens as quoted strings */ public toString(): string { - let output = `for=${Forwarded.#formatDirective(this.for)}`; const { by, host, secret, proto } = this; - output += - typeof by === "string" ? `;by=${Forwarded.#formatDirective(by)}` : ""; - output += - typeof host === "string" - ? `;host=${Forwarded.#formatDirective(host)}` - : ""; - output += typeof secret === "string" ? `;secret=${secret}` : ""; - output += typeof proto === "string" ? `;proto=${proto}` : ""; - return output; + return Object.entries({ for: this.for, by, host, secret, proto }) + .filter((entry): entry is [string, string] => entry[1] !== null) + .map(([name, value]) => `${name}=${quote(value)}`) + .join(";"); } /** The valid elements of the header (see `.parse()`) */ @@ -81,10 +88,6 @@ export class Forwarded implements IForwarded { .map((element) => Forwarded.#parseElement(element)); } - static #formatDirective(input: string): string { - return input.startsWith("[") && input.includes("]") ? `"${input}"` : input; - } - static #parseElement(input: string): Forwarded | null { const parameters = new Map(); const pairs = split(input, ";").filter((pair) => pair.trim() !== ""); @@ -99,13 +102,18 @@ export class Forwarded implements IForwarded { } parameters.set(key, unquote(value)); } - return new Forwarded({ - for: parameters.get("for") ?? "unknown", - by: parameters.get("by") ?? null, - host: parameters.get("host") ?? null, - proto: parameters.get("proto") ?? null, - secret: parameters.get("secret") ?? null, - }); + try { + return new Forwarded({ + for: parameters.get("for") ?? "unknown", + by: parameters.get("by") ?? null, + host: parameters.get("host") ?? null, + proto: parameters.get("proto") ?? null, + secret: parameters.get("secret") ?? null, + }); + } catch { + // E.g. control characters in a quoted string + return null; + } } } diff --git a/packages/binden/src/headers/if-modified-since.ts b/packages/binden/src/headers/if-modified-since.ts index da75ec57..878816f3 100644 --- a/packages/binden/src/headers/if-modified-since.ts +++ b/packages/binden/src/headers/if-modified-since.ts @@ -7,8 +7,19 @@ export interface IIfModifiedSince { export class IfModifiedSince implements IIfModifiedSince { readonly #date: Date; + /** + * A date that is an HTTP-date (from the year 0 to 9999): an invalid one + * throws a `TypeError` + */ public constructor({ date }: IIfModifiedSince) { - this.#date = new Date(date); + const value = new Date(date); + const year = value.getUTCFullYear(); + if (Number.isNaN(year)) { + throw new TypeError("Invalid date"); + } else if (year < 0 || year > 9999) { + throw new TypeError("The date is not an HTTP-date"); + } + this.#date = value; } public get date(): Date { diff --git a/packages/binden/src/headers/list.ts b/packages/binden/src/headers/list.ts index 4f035b9f..abd87c37 100644 --- a/packages/binden/src/headers/list.ts +++ b/packages/binden/src/headers/list.ts @@ -1,3 +1,14 @@ +/** A token (RFC 9110, section 5.6.2) */ +export const token = /^[!#$%&'*+.^_`|~\w-]+$/u; + +/** + * The characters of a field value (RFC 9110, section 5.5): no control + * characters but tabs, and nothing above `\xFF` + */ +export const fieldValue = + // eslint-disable-next-line no-control-regex -- control characters on purpose + /^[^\u0000-\u0008\u000A-\u001F\u007F\u{100}-\u{10FFFF}]*$/u; + /** * Splits a field value on the separators (e.g. `,` of a list, or `;` of * parameters) that are not in a quoted string (RFC 9110, section 5.6.4) @@ -32,3 +43,10 @@ export function unquote(value: string): string { ? value.slice(1, -1).replaceAll(/\\(?.)/gu, "$") : value; } + +/** A token as is, anything else as a quoted string (RFC 9110, section 5.6.4) */ +export function quote(value: string): string { + return token.test(value) + ? value + : `"${value.replaceAll(/(?["\\])/gu, "\\$")}"`; +} diff --git a/packages/binden/test/headers/authorization.test.ts b/packages/binden/test/headers/authorization.test.ts index 483b6f1b..0e11b6e2 100644 --- a/packages/binden/test/headers/authorization.test.ts +++ b/packages/binden/test/headers/authorization.test.ts @@ -1,7 +1,7 @@ -import { deepStrictEqual, ok } from "node:assert"; +import { deepStrictEqual, ok, throws } from "node:assert"; import { suite, test } from "node:test"; -import { Authorization } from "../../index.js"; +import { Authorization, type IAuthenticationType } from "../../index.js"; suite("Authorization", () => { test("constructor", () => { @@ -21,6 +21,36 @@ suite("Authorization", () => { deepStrictEqual(authorization.toString(), type); }); + test("constructor (invalid input)", () => { + // Like the parser: a supported scheme in its usual casing + for (const type of ["bearer", "Unknown", "Bearer abc", ""]) { + throws( + () => new Authorization({ type: type as IAuthenticationType }), + new TypeError(`Unsupported authentication scheme: ${type}`), + ); + } + // Credentials that a field value can carry, without surrounding whitespace + for (const credentials of [ + "", + " abc", + "abc ", + "a\r\nb", + "a\u0000b", + "\u20AC", + ]) { + throws( + () => new Authorization({ type: "Bearer", credentials }), + new TypeError("Invalid credentials"), + JSON.stringify(credentials), + ); + } + deepStrictEqual( + new Authorization({ type: "Digest", credentials: `a="b c",\td=\xE9` }) + .credentials, + `a="b c",\td=\xE9`, + ); + }); + test("Authorization.fromString()", () => { deepStrictEqual(Authorization.fromString(), null); deepStrictEqual(Authorization.fromString(""), null); @@ -53,6 +83,8 @@ suite("Authorization", () => { // Not a scheme deepStrictEqual(parse("Bearer\tabc"), null); deepStrictEqual(parse("Bear er abc"), null); + // Credentials that the constructor refuses + deepStrictEqual(parse("Bearer a\u0001b"), null); }); test("Authorization.isValidType()", () => { diff --git a/packages/binden/test/headers/content-encoding.test.ts b/packages/binden/test/headers/content-encoding.test.ts index b45cd458..eb64c7fc 100644 --- a/packages/binden/test/headers/content-encoding.test.ts +++ b/packages/binden/test/headers/content-encoding.test.ts @@ -1,7 +1,7 @@ -import { deepStrictEqual } from "node:assert"; +import { deepStrictEqual, throws } from "node:assert"; import { suite, test } from "node:test"; -import { ContentEncoding } from "../../index.js"; +import { ContentEncoding, type IEncodings } from "../../index.js"; suite("ContentEncoding", () => { test("constructor", () => { @@ -11,6 +11,16 @@ suite("ContentEncoding", () => { deepStrictEqual(ac.toString(), encoding); }); + test("constructor (invalid input)", () => { + // Like the parser: a known coding (in lowercase) + for (const encoding of ["identity", "GZIP", "gzip, br", "*", ""]) { + throws( + () => new ContentEncoding({ encoding: encoding as IEncodings }), + new TypeError(`Unknown content coding: ${encoding}`), + ); + } + }); + test("ContentEncoding.fromString()", () => { const parse = (input?: string): string[] | null => ContentEncoding.fromString(input)?.map((e) => e.toString()) ?? null; diff --git a/packages/binden/test/headers/forwarded.test.ts b/packages/binden/test/headers/forwarded.test.ts index ad11c4f8..3bc75212 100644 --- a/packages/binden/test/headers/forwarded.test.ts +++ b/packages/binden/test/headers/forwarded.test.ts @@ -1,7 +1,7 @@ -import { deepStrictEqual } from "node:assert"; +import { deepStrictEqual, throws } from "node:assert"; import { suite, test } from "node:test"; -import { Forwarded } from "../../index.js"; +import { Forwarded, type IForwarded } from "../../index.js"; suite("Forwarded", () => { test("constructor", () => { @@ -23,6 +23,45 @@ suite("Forwarded", () => { ); }); + test("constructor (invalid input)", () => { + const invalid: [Record, string][] = [ + [{ for: 1 }, "for"], + [{}, "for"], + [{ for: null }, "for"], + [{ for: "a\u0000" }, "for"], + [{ for: "a", by: "\n" }, "by"], + [{ for: "a", host: "\u20AC" }, "host"], + [{ for: "a", secret: 1 }, "secret"], + [{ for: "a", proto: "\u007F" }, "proto"], + ]; + for (const [input, name] of invalid) { + throws( + () => new Forwarded(input as unknown as IForwarded), + new TypeError(`Invalid \`${name}\` parameter`), + JSON.stringify(input), + ); + } + }); + + test(".toString() (quoted strings)", () => { + // Values that are not tokens are quoted (and their quotes escaped), so + // that the element is parsed back as it is + const forwarded = new Forwarded({ + for: "", + by: `a"b\\c`, + host: "example.com:8080", + secret: "s e,c;r=t", + proto: "\thttps\xE9", + }); + const output = `for="";by="a\\"b\\\\c";host="example.com:8080";secret="s e,c;r=t";proto="\thttps\xE9"`; + deepStrictEqual(forwarded.toString(), output); + const [parsed] = Forwarded.parse(output); + deepStrictEqual( + [parsed?.for, parsed?.by, parsed?.host, parsed?.secret, parsed?.proto], + ["", `a"b\\c`, "example.com:8080", "s e,c;r=t", "\thttps\xE9"], + ); + }); + test(".fromString()", () => { const input = ` For="[2001:db8:cafe::17]:4711";proto=https , for = 192.0.2.60 ; PROTO = http ; By = 203.0.113.43; secRet = SECRET; hoSt = localhost, ignored=1, ignored; k=v, for="", for="`; @@ -70,13 +109,19 @@ suite("Forwarded", () => { deepStrictEqual(parse(), []); deepStrictEqual( parse(`for=a;proto=https, garbage, for="b,c", ext=1, , for=d;for=e`), - ["for=a;proto=https", null, "for=b,c", "for=unknown", null], + ["for=a;proto=https", null, `for="b,c"`, "for=unknown", null], ); // The values that some proxies send unquoted deepStrictEqual(parse("for=2001:db8::1;proto=https, for=[::1]:80"), [ - "for=2001:db8::1;proto=https", + `for="2001:db8::1";proto=https`, `for="[::1]:80"`, ]); + // Control characters (in a quoted string, or a value without spaces) + deepStrictEqual(parse(`for="a\u0001", for=b\u0002, for=c`), [ + null, + null, + "for=c", + ]); // A repeated parameter, also in another case, is ambiguous deepStrictEqual(parse("proto=http;PROTO=https"), [null]); }); diff --git a/packages/binden/test/headers/if-modified-since.test.ts b/packages/binden/test/headers/if-modified-since.test.ts index 15a25b26..6369d6ca 100644 --- a/packages/binden/test/headers/if-modified-since.test.ts +++ b/packages/binden/test/headers/if-modified-since.test.ts @@ -1,4 +1,4 @@ -import { deepStrictEqual, ok } from "node:assert"; +import { deepStrictEqual, ok, throws } from "node:assert"; import { suite, test } from "node:test"; import { IfModifiedSince } from "../../index.js"; @@ -12,11 +12,25 @@ suite("If-Modified-Since", () => { }); test("constructor (invalid Date)", () => { - const input = "invalid"; - const date = new Date(input); - const ac = new IfModifiedSince({ date }); - deepStrictEqual(ac.date.getTime(), NaN); - deepStrictEqual(ac.toString(), "Invalid Date"); + throws( + () => new IfModifiedSince({ date: new Date("invalid") }), + new TypeError("Invalid date"), + ); + // Not an HTTP-date: a year out of 0..9999 + for (const year of [-1, 10000]) { + const date = new Date(Date.UTC(2000, 0, 1)); + date.setUTCFullYear(year); + throws( + () => new IfModifiedSince({ date }), + new TypeError("The date is not an HTTP-date"), + `${year}`, + ); + } + for (const year of [0, 9999]) { + const date = new Date(Date.UTC(2000, 0, 1)); + date.setUTCFullYear(year); + deepStrictEqual(new IfModifiedSince({ date }).date, date); + } }); test("IfModifiedSince.fromString()", () => { From 7d04a56583550d1b26bb6ab62bedd9534e7b508a Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 23:34:41 +0300 Subject: [PATCH 112/675] feat: accept the zstd content coding in binden Browsers send `zstd` (RFC 8878) in Accept-Encoding, and Node.js has its decoder: it is now a known coding of AcceptEncoding and ContentEncoding, so that a negotiation can select it and a body parser can tell a zstd body from an unknown coding. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/headers/accept-encoding.ts | 3 ++- packages/binden/src/headers/content-encoding.ts | 4 +++- packages/binden/test/headers/accept-encoding.test.ts | 8 ++++++++ packages/binden/test/headers/content-encoding.test.ts | 6 ++++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/binden/src/headers/accept-encoding.ts b/packages/binden/src/headers/accept-encoding.ts index a86da105..f3b65656 100644 --- a/packages/binden/src/headers/accept-encoding.ts +++ b/packages/binden/src/headers/accept-encoding.ts @@ -1,5 +1,5 @@ export type IAcceptEncodings = - "*" | "br" | "compress" | "deflate" | "gzip" | "identity" | "x-gzip"; + "*" | "br" | "compress" | "deflate" | "gzip" | "identity" | "x-gzip" | "zstd"; export interface IAcceptEncoding { encoding: IAcceptEncodings; @@ -78,6 +78,7 @@ export class AcceptEncoding implements IAcceptEncoding { "compress", "deflate", "br", + "zstd", "*", "identity", ]; diff --git a/packages/binden/src/headers/content-encoding.ts b/packages/binden/src/headers/content-encoding.ts index cbc5af23..3720c030 100644 --- a/packages/binden/src/headers/content-encoding.ts +++ b/packages/binden/src/headers/content-encoding.ts @@ -1,4 +1,5 @@ -export type IEncodings = "br" | "compress" | "deflate" | "gzip" | "x-gzip"; +export type IEncodings = + "br" | "compress" | "deflate" | "gzip" | "x-gzip" | "zstd"; export interface IContentEncoding { encoding: IEncodings; @@ -55,6 +56,7 @@ export class ContentEncoding implements IContentEncoding { "compress", "deflate", "br", + "zstd", ]); } diff --git a/packages/binden/test/headers/accept-encoding.test.ts b/packages/binden/test/headers/accept-encoding.test.ts index 5c9d4078..b0cc5743 100644 --- a/packages/binden/test/headers/accept-encoding.test.ts +++ b/packages/binden/test/headers/accept-encoding.test.ts @@ -62,6 +62,14 @@ suite("AcceptEncoding", () => { deepStrictEqual(parsed[6].toString(), "identity;q=0.1"); }); + test("AcceptEncoding.fromString() (zstd, RFC 8878)", () => { + deepStrictEqual( + AcceptEncoding.fromString("ZSTD;q=0.9, br").map((e) => e.toString()), + ["br", "zstd;q=0.9"], + ); + deepStrictEqual(new AcceptEncoding({ encoding: "zstd" }).encoding, "zstd"); + }); + test("AcceptEncoding.fromString() (case and whitespace insensitive)", () => { const parsed = AcceptEncoding.fromString( "GZIP;Q=0.5, Br ; q = 0.4, deflate; q=1, X-Gzip", diff --git a/packages/binden/test/headers/content-encoding.test.ts b/packages/binden/test/headers/content-encoding.test.ts index eb64c7fc..c16fc4bf 100644 --- a/packages/binden/test/headers/content-encoding.test.ts +++ b/packages/binden/test/headers/content-encoding.test.ts @@ -33,6 +33,12 @@ suite("ContentEncoding", () => { "br", "x-gzip", ]); + // RFC 8878 + deepStrictEqual(parse("gzip, Zstd"), ["zstd", "gzip"]); + deepStrictEqual( + new ContentEncoding({ encoding: "zstd" }).toString(), + "zstd", + ); // An unknown or malformed coding: the content cannot be decoded for (const input of ["gzip, ignored", "gzip;q=1", "gzip br", "identity"]) { deepStrictEqual(parse(input), null, input); From 2a1cc227faaa667b6dfbf39fb11cfa02e70b6a50 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 23:44:04 +0300 Subject: [PATCH 113/675] feat: negotiate the content of a response by the request headers in binden Nothing selected a representation by the Accept headers (RFC 9110, section 12.5): only the elements of Accept-Encoding were parsed. The new Accept, AcceptLanguage and AcceptCharset classes parse their headers like the other ones (fromString(), toString() and validating constructors), and every one of them (AcceptEncoding too) has a static negotiate() that selects the preferred offer. An offer takes the weight of the most specific element that matches it, so that `text/html;q=0` excludes HTML even with `*/*`; ties go to the more specific element, then the earlier element, then the earlier offer. Language ranges match by basic filtering (RFC 4647), and `identity` stays acceptable unless excluded. request.accepts(), acceptsCharsets(), acceptsEncodings() and acceptsLanguages() negotiate with the headers of the request: the first offer without the header, `false` when none is acceptable. Co-Authored-By: Claude Opus 5.5 --- packages/binden/index.ts | 3 + packages/binden/src/headers/accept-charset.ts | 88 +++++++ .../binden/src/headers/accept-encoding.ts | 75 +++++- .../binden/src/headers/accept-language.ts | 99 ++++++++ packages/binden/src/headers/accept.ts | 172 +++++++++++++ packages/binden/src/headers/list.ts | 97 +++++++ packages/binden/src/request.ts | 35 +++ .../test/headers/accept-charset.test.ts | 82 ++++++ .../test/headers/accept-encoding.test.ts | 48 ++++ .../test/headers/accept-language.test.ts | 114 +++++++++ packages/binden/test/headers/accept.test.ts | 239 ++++++++++++++++++ packages/binden/test/request.test.ts | 29 +++ 12 files changed, 1068 insertions(+), 13 deletions(-) create mode 100644 packages/binden/src/headers/accept-charset.ts create mode 100644 packages/binden/src/headers/accept-language.ts create mode 100644 packages/binden/src/headers/accept.ts create mode 100644 packages/binden/test/headers/accept-charset.test.ts create mode 100644 packages/binden/test/headers/accept-language.test.ts create mode 100644 packages/binden/test/headers/accept.test.ts diff --git a/packages/binden/index.ts b/packages/binden/index.ts index fd638c9a..4384a99f 100644 --- a/packages/binden/index.ts +++ b/packages/binden/index.ts @@ -1,4 +1,7 @@ +export * from "./src/headers/accept.js"; +export * from "./src/headers/accept-charset.js"; export * from "./src/headers/accept-encoding.js"; +export * from "./src/headers/accept-language.js"; export * from "./src/headers/authorization.js"; export * from "./src/headers/content-encoding.js"; export * from "./src/headers/content-range.js"; diff --git a/packages/binden/src/headers/accept-charset.ts b/packages/binden/src/headers/accept-charset.ts new file mode 100644 index 00000000..c4dfd8fa --- /dev/null +++ b/packages/binden/src/headers/accept-charset.ts @@ -0,0 +1,88 @@ +import { isQvalue, preferred, token, weighted } from "./list.js"; + +export interface IAcceptCharset { + /** A charset (a token), or `*` */ + charset: string; + q_value?: number | null | undefined; +} + +/** An element of the `Accept-Charset` header (RFC 9110, section 12.5.2) */ +export class AcceptCharset implements IAcceptCharset { + readonly #charset: string; + readonly #q_value: number | null; + + /** + * A charset (a token, or `*`) and its weight (a qvalue). Invalid ones throw + * a `TypeError`. + */ + public constructor({ charset, q_value = null }: IAcceptCharset) { + if (!token.test(charset)) { + throw new TypeError(`Invalid charset: ${charset}`); + } else if (!isQvalue(q_value)) { + throw new TypeError(`\`q_value\` is not a qvalue: ${q_value}`); + } + this.#charset = charset; + this.#q_value = q_value; + } + + public get charset(): string { + return this.#charset; + } + + public get q_value(): number | null { + return this.#q_value; + } + + public toString(): string { + const { charset, q_value } = this; + return q_value === null ? charset : `${charset};q=${q_value}`; + } + + /** + * The valid elements of the header (other parameters than the weight are + * ignored), from the greatest weight (a missing one is 1), in their order on + * a tie + */ + public static fromString(input?: string[] | string): AcceptCharset[] { + return AcceptCharset.#parse(input).toSorted( + ({ q_value: a }, { q_value: b }) => (b ?? 1) - (a ?? 1), + ); + } + + /** + * The preferred one of the offered charsets, as given: the first one without + * the header, otherwise the one of the greatest weight, given by the element + * of the charset (case-insensitive), then `*`, or `false` when none is + * acceptable. An offer that is not a token throws a `TypeError`. + */ + public static negotiate( + offers: readonly string[], + input?: string[] | string, + ): string | false { + for (const offer of offers) { + if (!token.test(offer) || offer === "*") { + throw new TypeError(`Invalid charset: ${offer}`); + } + } + if (typeof input === "undefined") { + return offers.at(0) ?? false; + } + return preferred(offers, AcceptCharset.#parse(input), (element, offer) => { + const charset = element.charset.toLowerCase(); + return charset === "*" ? 0 : charset === offer.toLowerCase() ? 1 : null; + }); + } + + /** The valid elements of the header, in their order */ + static #parse(input?: string[] | string): AcceptCharset[] { + return weighted(input).flatMap(({ value: charset, q_value }) => { + try { + return [new AcceptCharset({ charset, q_value })]; + } catch { + return []; + } + }); + } +} + +export default AcceptCharset; diff --git a/packages/binden/src/headers/accept-encoding.ts b/packages/binden/src/headers/accept-encoding.ts index f3b65656..dfd95258 100644 --- a/packages/binden/src/headers/accept-encoding.ts +++ b/packages/binden/src/headers/accept-encoding.ts @@ -1,3 +1,5 @@ +import { preferred, qvalue, token } from "./list.js"; + export type IAcceptEncodings = "*" | "br" | "compress" | "deflate" | "gzip" | "identity" | "x-gzip" | "zstd"; @@ -6,9 +8,6 @@ export interface IAcceptEncoding { q_value?: number | null; } -/** A weight from 0 to 1, with at most 3 decimals (RFC 9110, section 12.4.2) */ -const qvalue = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/u; - export class AcceptEncoding implements IAcceptEncoding { readonly #encoding: IAcceptEncodings; readonly #q_value: number | null; @@ -37,16 +36,71 @@ export class AcceptEncoding implements IAcceptEncoding { } public static fromString(string?: string[] | string): AcceptEncoding[] { - let input = string; + return AcceptEncoding.#parse(string) + .filter((e): e is [IAcceptEncodings, number | null] => + AcceptEncoding.#encodings.includes(e[0]), + ) + .map(([encoding, q_value]) => new AcceptEncoding({ encoding, q_value })) + .sort(({ q_value: a }, { q_value: b }) => (b ?? 1) - (a ?? 1)); + } + + /** + * The preferred one of the offered content codings (tokens), as given: the + * first one without the header, otherwise the one of the greatest weight, + * given by the element of the coding (case-insensitive, `x-gzip` is `gzip`), + * then `*`, or `false` when none is acceptable (RFC 9110, section 12.5.3). + * Unknown codings of the header count too. `identity` is acceptable unless + * excluded (`identity;q=0`, or `*;q=0` without `identity`), but the least + * preferred when the header does not name it. An offer that is not a token + * throws a `TypeError`. + */ + public static negotiate( + offers: readonly string[], + input?: string[] | string, + ): string | false { + for (const offer of offers) { + if (!token.test(offer) || offer === "*") { + throw new TypeError(`Invalid content coding: ${offer}`); + } + } + if (typeof input === "undefined") { + return offers.at(0) ?? false; + } + const elements = AcceptEncoding.#parse(input).map(([coding, q_value]) => ({ + coding: AcceptEncoding.#canonical(coding), + q_value, + implicit: false, + })); + // The smallest weight, less specific than any element of the header + elements.push({ coding: "identity", q_value: 0.001, implicit: true }); + return preferred(offers, elements, ({ coding, implicit }, offer) => { + const match = coding === AcceptEncoding.#canonical(offer); + if (implicit) { + return match ? -1 : null; + } + return coding === "*" ? 0 : match ? 1 : null; + }); + } + + /** A content coding in lowercase, with `x-gzip` as `gzip` */ + static #canonical(coding: string): string { + const lower = coding.toLowerCase(); + return lower === "x-gzip" ? "gzip" : lower; + } + /** + * Every element of the header (with its coding in lowercase) and its weight, + * in their order: an element whose weight is not a qvalue is skipped + */ + static #parse( + input: string[] | string | undefined, + ): [string, number | null][] { if (typeof input === "undefined") { return []; - } else if (Array.isArray(input)) { - input = input.join(","); } // Content codings and the `q` parameter are case-insensitive (RFC 9110) - return input + return (Array.isArray(input) ? input.join(",") : input) .split(",") .map<[string, number | null] | null>((part) => { const [coding = "", ...params] = part @@ -64,12 +118,7 @@ export class AcceptEncoding implements IAcceptEncoding { ? [coding.toLowerCase(), Number(value)] : null; }) - .filter( - (e): e is [IAcceptEncodings, number | null] => - e !== null && AcceptEncoding.#encodings.includes(e[0]), - ) - .map(([encoding, q_value]) => new AcceptEncoding({ encoding, q_value })) - .sort(({ q_value: a }, { q_value: b }) => (b ?? 1) - (a ?? 1)); + .filter((e) => e !== null); } static readonly #encodings: readonly string[] = [ diff --git a/packages/binden/src/headers/accept-language.ts b/packages/binden/src/headers/accept-language.ts new file mode 100644 index 00000000..e769ddfc --- /dev/null +++ b/packages/binden/src/headers/accept-language.ts @@ -0,0 +1,99 @@ +import { isQvalue, preferred, weighted } from "./list.js"; + +export interface IAcceptLanguage { + /** A language range: `*`, or a language tag (e.g. `en`, `en-US`) */ + language: string; + q_value?: number | null | undefined; +} + +/** `1*8ALPHA *( "-" 1*8alphanum )` (RFC 4647, section 2.1) */ +const tag = /^[A-Za-z]{1,8}(?:-[A-Za-z\d]{1,8})*$/u; + +/** An element of the `Accept-Language` header (RFC 9110, section 12.5.4) */ +export class AcceptLanguage implements IAcceptLanguage { + readonly #language: string; + readonly #q_value: number | null; + + /** + * A language range (`*`, or subtags of up to 8 letters and digits, the first + * one of letters) and its weight (a qvalue). Invalid ones throw a + * `TypeError`. + */ + public constructor({ language, q_value = null }: IAcceptLanguage) { + if (language !== "*" && !tag.test(language)) { + throw new TypeError(`Invalid language range: ${language}`); + } else if (!isQvalue(q_value)) { + throw new TypeError(`\`q_value\` is not a qvalue: ${q_value}`); + } + this.#language = language; + this.#q_value = q_value; + } + + public get language(): string { + return this.#language; + } + + public get q_value(): number | null { + return this.#q_value; + } + + public toString(): string { + const { language, q_value } = this; + return q_value === null ? language : `${language};q=${q_value}`; + } + + /** + * The valid elements of the header (other parameters than the weight are + * ignored), from the greatest weight (a missing one is 1), in their order on + * a tie + */ + public static fromString(input?: string[] | string): AcceptLanguage[] { + return AcceptLanguage.#parse(input).toSorted( + ({ q_value: a }, { q_value: b }) => (b ?? 1) - (a ?? 1), + ); + } + + /** + * The preferred one of the offered language tags, as given: the first one + * without the header, otherwise the one of the greatest weight, given by the + * longest language range that is the tag or one of its prefixes (basic + * filtering, RFC 4647, section 3.3.1), then `*`, or `false` when none is + * acceptable. An offer that is not a language tag throws a `TypeError`. + */ + public static negotiate( + offers: readonly string[], + input?: string[] | string, + ): string | false { + for (const offer of offers) { + if (!tag.test(offer)) { + throw new TypeError(`Invalid language tag: ${offer}`); + } + } + if (typeof input === "undefined") { + return offers.at(0) ?? false; + } + return preferred(offers, AcceptLanguage.#parse(input), (range, offer) => { + const language = range.language.toLowerCase(); + const lower = offer.toLowerCase(); + if (language === "*") { + return 0; + } else if (lower === language || lower.startsWith(`${language}-`)) { + return language.split("-").length; + } + return null; + }); + } + + /** The valid elements of the header, in their order */ + static #parse(input?: string[] | string): AcceptLanguage[] { + return weighted(input).flatMap(({ value: language, q_value }) => { + try { + return [new AcceptLanguage({ language, q_value })]; + } catch { + return []; + } + }); + } +} + +export default AcceptLanguage; diff --git a/packages/binden/src/headers/accept.ts b/packages/binden/src/headers/accept.ts new file mode 100644 index 00000000..c5f22bf8 --- /dev/null +++ b/packages/binden/src/headers/accept.ts @@ -0,0 +1,172 @@ +import { + fieldValue, + isQvalue, + preferred, + quote, + token, + weighted, +} from "./list.js"; + +export interface IAccept { + /** A media range: `*\/*`, `type/*` or `type/subtype` */ + type: string; + /** The parameters of the media range (their names are case-insensitive) */ + parameters?: Readonly> | undefined; + q_value?: number | null | undefined; +} + +/** A media type of an offer, with its parameters by lowercase name */ +interface IMediaType { + readonly type: string; + readonly subtype: string; + readonly parameters: ReadonlyMap; +} + +/** An element of the `Accept` header (RFC 9110, section 12.5.1) */ +export class Accept implements IAccept { + readonly #type: string; + readonly #parameters: ReadonlyMap; + readonly #q_value: number | null; + + /** + * A media range (`*\/*`, `type/*` or `type/subtype`, of tokens), its + * parameters (a token as name, other than `q`, and a value without control + * characters) and its weight (a qvalue). Invalid ones throw a `TypeError`. + */ + public constructor({ type, parameters = {}, q_value = null }: IAccept) { + const [main = "", subtype = "", ...rest] = type.split("/"); + if ( + rest.length > 0 || + !token.test(main) || + !token.test(subtype) || + (main === "*" && subtype !== "*") + ) { + throw new TypeError(`Invalid media range: ${type}`); + } else if (!isQvalue(q_value)) { + throw new TypeError(`\`q_value\` is not a qvalue: ${q_value}`); + } + const all = new Map(); + for (const [name, value] of Object.entries(parameters)) { + const key = name.toLowerCase(); + if (!token.test(key) || key === "q") { + throw new TypeError(`Invalid parameter name: ${name}`); + } else if (!fieldValue.test(value)) { + throw new TypeError(`Invalid value of the parameter ${name}`); + } else if (!all.has(key)) { + all.set(key, value); + } + } + this.#type = type; + this.#parameters = all; + this.#q_value = q_value; + } + + public get type(): string { + return this.#type; + } + + /** Every parameter (but the weight), by its name in lowercase */ + public get parameters(): Record { + return Object.fromEntries(this.#parameters); + } + + public get q_value(): number | null { + return this.#q_value; + } + + public toString(): string { + const parameters = [...this.#parameters].map( + ([name, value]) => `;${name}=${quote(value)}`, + ); + const weight = this.#q_value === null ? "" : `;q=${this.#q_value}`; + return `${this.#type}${parameters.join("")}${weight}`; + } + + /** + * The valid elements of the header (with their media ranges in lowercase), + * from the greatest weight (a missing one is 1), in their order on a tie + */ + public static fromString(input?: string[] | string): Accept[] { + return Accept.#parse(input).toSorted( + ({ q_value: a }, { q_value: b }) => (b ?? 1) - (a ?? 1), + ); + } + + /** + * The preferred one of the offered media types (`type/subtype`, maybe with + * parameters), as given: the first one without the header, otherwise the one + * of the greatest weight, given by the most specific media range that matches + * it (`text/html;level=1`, then `text/html`, `text/*` and `*\/*`), or `false` + * when none is acceptable (RFC 9110, section 12.5.1). An offer that is not a + * media type throws a `TypeError`. + */ + public static negotiate( + offers: readonly string[], + input?: string[] | string, + ): string | false { + const types = new Map(offers.map((offer) => [offer, Accept.#offer(offer)])); + if (typeof input === "undefined") { + return offers.at(0) ?? false; + } + return preferred(offers, Accept.#parse(input), (range, offer) => + Accept.#specificity(range, types.get(offer) as IMediaType), + ); + } + + /** The valid elements of the header, in their order */ + static #parse(input?: string[] | string): Accept[] { + return weighted(input).flatMap(({ value, parameters, q_value }) => { + try { + const type = value.toLowerCase(); + const all = Object.fromEntries(parameters); + return [new Accept({ type, parameters: all, q_value })]; + } catch { + return []; + } + }); + } + + /** A media type, without wildcards nor a weight, or a `TypeError` */ + static #offer(offer: string): IMediaType { + // One element (without a weight): an offer with a `,` would be several + const [element = null, ...others] = weighted(offer); + const [type = "", subtype = "", ...rest] = + element?.value.toLowerCase().split("/") ?? []; + if ( + element === null || + others.length > 0 || + element.q_value !== null || + rest.length > 0 || + !token.test(type) || + !token.test(subtype) || + type === "*" || + subtype === "*" + ) { + throw new TypeError(`Invalid media type: ${offer}`); + } + return { type, subtype, parameters: new Map(element.parameters) }; + } + + /** + * How specific a media range that matches the media type is (its type, + * subtype and parameters), or `null` when it does not match + */ + static #specificity(range: Accept, media: IMediaType): number | null { + const [type = "", subtype = ""] = range.type.toLowerCase().split("/"); + const parameters = [...range.#parameters]; + const level = type === "*" ? 0 : subtype === "*" ? 1 : 2; + if ( + (level > 0 && type !== media.type) || + (level > 1 && subtype !== media.subtype) || + parameters.some( + ([name, value]) => + media.parameters.get(name)?.toLowerCase() !== value.toLowerCase(), + ) + ) { + return null; + } + return level * 1000 + parameters.length; + } +} + +export default Accept; diff --git a/packages/binden/src/headers/list.ts b/packages/binden/src/headers/list.ts index abd87c37..59bc4e03 100644 --- a/packages/binden/src/headers/list.ts +++ b/packages/binden/src/headers/list.ts @@ -1,6 +1,9 @@ /** A token (RFC 9110, section 5.6.2) */ export const token = /^[!#$%&'*+.^_`|~\w-]+$/u; +/** A weight from 0 to 1, with at most 3 decimals (RFC 9110, section 12.4.2) */ +export const qvalue = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/u; + /** * The characters of a field value (RFC 9110, section 5.5): no control * characters but tabs, and nothing above `\xFF` @@ -9,6 +12,10 @@ export const fieldValue = // eslint-disable-next-line no-control-regex -- control characters on purpose /^[^\u0000-\u0008\u000A-\u001F\u007F\u{100}-\u{10FFFF}]*$/u; +/** `name=value`, where the value is a token or a quoted string */ +const parameter = + /^\s*(?[!#$%&'*+.^_`|~\w-]+)\s*=\s*(?[!#$%&'*+.^_`|~\w-]+|"(?:[^"\\]|\\.)*")\s*$/u; + /** * Splits a field value on the separators (e.g. `,` of a list, or `;` of * parameters) that are not in a quoted string (RFC 9110, section 5.6.4) @@ -50,3 +57,93 @@ export function quote(value: string): string { ? value : `"${value.replaceAll(/(?["\\])/gu, "\\$")}"`; } + +/** Whether a weight is a qvalue (`null` is the default weight) */ +export function isQvalue(q_value: number | null): boolean { + return q_value === null || qvalue.test(`${q_value}`); +} + +/** An element of a list with weights: its value, parameters and weight */ +export interface IWeighted { + readonly value: string; + /** The parameters before the weight, with their names in lowercase */ + readonly parameters: readonly (readonly [string, string])[]; + readonly q_value: number | null; +} + +/** The element of a list with weights, or `null` when it is malformed */ +function element(input: string): IWeighted | null { + const [head = "", ...rest] = split(input, ";"); + const value = head.trim(); + const parameters: [string, string][] = []; + let q_value: number | null = null; + // Empty parameters are allowed (RFC 9110, section 5.6.6) + for (const part of rest.filter((item) => item.trim() !== "")) { + if (q_value !== null) { + // Extensions after the weight (RFC 7231, section 5.3.2) are ignored + break; + } + const { name = "", value: raw = "" } = parameter.exec(part)?.groups ?? {}; + const key = name.toLowerCase(); + if (key === "" || (key === "q" && !qvalue.test(raw))) { + return null; + } else if (key === "q") { + q_value = Number(raw); + } else { + parameters.push([key, unquote(raw)]); + } + } + return { value, parameters, q_value }; +} + +/** + * The elements of a list of values with weights (RFC 9110, section 12.4.2), + * in their order: malformed elements (e.g. with a weight that is not a + * qvalue) are skipped, and the values are left to check (an empty element + * has an empty value) + */ +export function weighted(input?: string[] | string): IWeighted[] { + const list = Array.isArray(input) ? input.join(",") : (input ?? ""); + return split(list, ",") + .map((part) => element(part)) + .filter((part) => part !== null); +} + +/** + * The preferred offer of a content negotiation (RFC 9110, section 12.5): + * every offer has the weight of the most specific element that matches it + * (`specificity` is `null` for an element that does not), the first one of + * them. The offer of the greatest weight wins, then the one of the more + * specific element, then of the earlier element, then the earlier offer. An + * offer of the weight 0 is not acceptable. + */ +export function preferred( + offers: readonly string[], + elements: readonly T[], + specificity: (element: T, offer: string) => number | null, +): string | false { + let best: string | false = false; + let rank = { q: 0, s: 0, index: 0 }; + for (const offer of offers) { + let match: typeof rank | null = null; + for (const [index, item] of elements.entries()) { + const s = specificity(item, offer); + if (s !== null && (match === null || s > match.s)) { + match = { q: item.q_value ?? 1, s, index }; + } + } + // The weight of the first acceptable offer is greater than 0 + if ( + match !== null && + match.q > 0 && + (match.q > rank.q || + (match.q === rank.q && + (match.s > rank.s || + (match.s === rank.s && match.index < rank.index)))) + ) { + best = offer; + rank = match; + } + } + return best; +} diff --git a/packages/binden/src/request.ts b/packages/binden/src/request.ts index 2b254c88..48efef8f 100644 --- a/packages/binden/src/request.ts +++ b/packages/binden/src/request.ts @@ -4,7 +4,10 @@ import { parse, type ParsedUrlQuery } from "node:querystring"; import { TLSSocket } from "node:tls"; import { BindenError } from "./error.js"; +import Accept from "./headers/accept.js"; +import AcceptCharset from "./headers/accept-charset.js"; import AcceptEncoding from "./headers/accept-encoding.js"; +import AcceptLanguage from "./headers/accept-language.js"; import Authorization from "./headers/authorization.js"; import ContentEncoding from "./headers/content-encoding.js"; import ContentType from "./headers/content-type.js"; @@ -34,6 +37,38 @@ export class BindenRequest extends IncomingMessage { #range?: readonly Range[]; #query?: ParsedUrlQuery; + /** + * The preferred one of the offered media types (e.g. `application/json`), by + * the `Accept` header (see `Accept.negotiate()`), or `false` + */ + public accepts(...types: string[]): string | false { + return Accept.negotiate(types, this.headers.accept); + } + + /** + * The preferred one of the offered charsets, by the `Accept-Charset` header + * (see `AcceptCharset.negotiate()`), or `false` + */ + public acceptsCharsets(...charsets: string[]): string | false { + return AcceptCharset.negotiate(charsets, this.headers["accept-charset"]); + } + + /** + * The preferred one of the offered content codings, by the + * `Accept-Encoding` header (see `AcceptEncoding.negotiate()`), or `false` + */ + public acceptsEncodings(...encodings: string[]): string | false { + return AcceptEncoding.negotiate(encodings, this.headers["accept-encoding"]); + } + + /** + * The preferred one of the offered language tags, by the `Accept-Language` + * header (see `AcceptLanguage.negotiate()`), or `false` + */ + public acceptsLanguages(...languages: string[]): string | false { + return AcceptLanguage.negotiate(languages, this.headers["accept-language"]); + } + public get accept_encoding(): AcceptEncoding[] { if (!this.#accept_encoding) { const ae = this.headers["accept-encoding"]; diff --git a/packages/binden/test/headers/accept-charset.test.ts b/packages/binden/test/headers/accept-charset.test.ts new file mode 100644 index 00000000..8d6ac8b4 --- /dev/null +++ b/packages/binden/test/headers/accept-charset.test.ts @@ -0,0 +1,82 @@ +import { deepStrictEqual, throws } from "node:assert"; +import { suite, test } from "node:test"; + +import { AcceptCharset } from "../../index.js"; + +suite("AcceptCharset", () => { + test("constructor", () => { + const element = new AcceptCharset({ charset: "utf-8", q_value: 0.5 }); + deepStrictEqual(element.charset, "utf-8"); + deepStrictEqual(element.q_value, 0.5); + deepStrictEqual(element.toString(), "utf-8;q=0.5"); + + const any = new AcceptCharset({ charset: "*" }); + deepStrictEqual(any.q_value, null); + deepStrictEqual(any.toString(), "*"); + }); + + test("constructor (invalid input)", () => { + for (const charset of ["utf 8", "", "a,b", "a;q=1"]) { + throws( + () => new AcceptCharset({ charset }), + new TypeError(`Invalid charset: ${charset}`), + ); + } + for (const q_value of [2, -0.5, 0.0001]) { + throws( + () => new AcceptCharset({ charset: "utf-8", q_value }), + new TypeError(`\`q_value\` is not a qvalue: ${q_value}`), + ); + } + }); + + test("AcceptCharset.fromString()", () => { + const parse = (input?: string[] | string): string[] => + AcceptCharset.fromString(input).map((element) => element.toString()); + + deepStrictEqual(parse(), []); + // RFC 9110, section 12.5.2; other parameters are ignored, malformed + // elements skipped + deepStrictEqual( + parse( + `iso-8859-5;x="1", unicode-1-1;q=0.8, *;q=0.1, "utf-8", utf-16;q=3`, + ), + ["iso-8859-5", "unicode-1-1;q=0.8", "*;q=0.1"], + ); + deepStrictEqual(parse(["a;q=0.5", "b"]), ["b", "a;q=0.5"]); + }); + + test("AcceptCharset.negotiate()", () => { + const negotiate = (input: string, ...offers: string[]): string | false => + AcceptCharset.negotiate(offers, input); + + deepStrictEqual( + negotiate("iso-8859-5, unicode-1-1;q=0.8", "unicode-1-1", "iso-8859-5"), + "iso-8859-5", + ); + deepStrictEqual(negotiate("iso-8859-5, unicode-1-1;q=0.8", "utf-8"), false); + // Case-insensitive, and the offer is returned as given + deepStrictEqual(negotiate("UTF-8", "utf-8"), "utf-8"); + // `*` matches any charset, but not those of an excluding element + deepStrictEqual( + negotiate("utf-8;q=0, *;q=0.5", "utf-8", "latin1"), + "latin1", + ); + deepStrictEqual(negotiate("utf-8;q=0, *;q=0.5", "utf-8"), false); + deepStrictEqual(negotiate("*;q=0.5, b;q=0.5", "a", "b"), "b"); + deepStrictEqual(negotiate("a;q=0.4, *;q=0.5", "a", "b"), "b"); + deepStrictEqual(negotiate("", "utf-8"), false); + // Without the header: the first offer + deepStrictEqual(AcceptCharset.negotiate(["b", "a"]), "b"); + deepStrictEqual(AcceptCharset.negotiate([]), false); + }); + + test("AcceptCharset.negotiate() (invalid offers)", () => { + for (const offer of ["*", "utf 8", ""]) { + throws( + () => AcceptCharset.negotiate(["utf-8", offer], "*"), + new TypeError(`Invalid charset: ${offer}`), + ); + } + }); +}); diff --git a/packages/binden/test/headers/accept-encoding.test.ts b/packages/binden/test/headers/accept-encoding.test.ts index b0cc5743..e8161180 100644 --- a/packages/binden/test/headers/accept-encoding.test.ts +++ b/packages/binden/test/headers/accept-encoding.test.ts @@ -107,6 +107,54 @@ suite("AcceptEncoding", () => { } }); + test("AcceptEncoding.negotiate()", () => { + const negotiate = (input: string, ...offers: string[]): string | false => + AcceptEncoding.negotiate(offers, input); + + deepStrictEqual(negotiate("gzip, br;q=0.9", "br", "gzip"), "gzip"); + deepStrictEqual(negotiate("GZIP;Q=0.5, Br;q=0.4", "br", "Gzip"), "Gzip"); + // `x-gzip` is `gzip` + deepStrictEqual(negotiate("x-gzip", "br", "gzip"), "gzip"); + deepStrictEqual(negotiate("gzip", "br", "x-gzip"), "x-gzip"); + // Unknown codings count too, and an invalid weight drops its element + deepStrictEqual(negotiate("foo;q=0, *", "foo", "gzip"), "gzip"); + deepStrictEqual(negotiate("foo;q=0.5, gzip;q=0.4", "gzip", "foo"), "foo"); + deepStrictEqual(negotiate("gzip;q=2, br", "gzip", "br"), "br"); + deepStrictEqual(negotiate("*;q=0.5, br;q=0.5", "gzip", "br"), "br"); + deepStrictEqual(negotiate("br", "gzip"), false); + deepStrictEqual(negotiate("br"), false); + // RFC 9110, section 12.5.3: `identity` is acceptable unless excluded + deepStrictEqual(negotiate("br;q=0", "br", "identity"), "identity"); + deepStrictEqual(negotiate("", "gzip", "identity"), "identity"); + deepStrictEqual(negotiate("", "gzip"), false); + deepStrictEqual(negotiate("identity;q=0", "identity"), false); + deepStrictEqual(negotiate("*;q=0", "identity"), false); + deepStrictEqual( + negotiate("*;q=0, IDENTITY", "gzip", "identity"), + "identity", + ); + deepStrictEqual(negotiate("*;q=0.5", "identity", "gzip"), "identity"); + // ... but the least preferred when the header does not name it + deepStrictEqual(negotiate("gzip;q=0.001", "identity", "gzip"), "gzip"); + deepStrictEqual(negotiate("gzip;q=0", "identity", "gzip"), "identity"); + // Without the header: the first offer + deepStrictEqual(AcceptEncoding.negotiate(["br", "gzip"]), "br"); + deepStrictEqual(AcceptEncoding.negotiate([]), false); + deepStrictEqual( + AcceptEncoding.negotiate(["br", "zstd"], ["br;q=0.5", "zstd"]), + "zstd", + ); + }); + + test("AcceptEncoding.negotiate() (invalid offers)", () => { + for (const offer of ["*", "g zip", ""]) { + throws( + () => AcceptEncoding.negotiate(["gzip", offer], "*"), + new TypeError(`Invalid content coding: ${offer}`), + ); + } + }); + test("constructor (invalid input)", () => { for (const q_value of [ 2, diff --git a/packages/binden/test/headers/accept-language.test.ts b/packages/binden/test/headers/accept-language.test.ts new file mode 100644 index 00000000..592b10b7 --- /dev/null +++ b/packages/binden/test/headers/accept-language.test.ts @@ -0,0 +1,114 @@ +import { deepStrictEqual, throws } from "node:assert"; +import { suite, test } from "node:test"; + +import { AcceptLanguage } from "../../index.js"; + +suite("AcceptLanguage", () => { + test("constructor", () => { + const element = new AcceptLanguage({ language: "en-US", q_value: 0.8 }); + deepStrictEqual(element.language, "en-US"); + deepStrictEqual(element.q_value, 0.8); + deepStrictEqual(element.toString(), "en-US;q=0.8"); + + const any = new AcceptLanguage({ language: "*" }); + deepStrictEqual(any.q_value, null); + deepStrictEqual(any.toString(), "*"); + deepStrictEqual( + new AcceptLanguage({ language: "zh-Hant-CN-x-private1" }).toString(), + "zh-Hant-CN-x-private1", + ); + }); + + test("constructor (invalid input)", () => { + for (const language of [ + "en_US", + "123", + "toolongtag", + "en-", + "-en", + "en--US", + "en-toolongtag", + "", + "**", + "en-*", + ]) { + throws( + () => new AcceptLanguage({ language }), + new TypeError(`Invalid language range: ${language}`), + ); + } + for (const q_value of [2, -0.5, 0.0001]) { + throws( + () => new AcceptLanguage({ language: "en", q_value }), + new TypeError(`\`q_value\` is not a qvalue: ${q_value}`), + ); + } + }); + + test("AcceptLanguage.fromString()", () => { + const parse = (input?: string[] | string): string[] => + AcceptLanguage.fromString(input).map((element) => element.toString()); + + deepStrictEqual(parse(), []); + deepStrictEqual(parse(""), []); + // From the greatest weight, in their order on a tie; other parameters + // are ignored, malformed elements skipped + deepStrictEqual( + parse( + "da, en-gb;q=0.8, en;q=0.7;x=1, fr;x=1;q=0.7, *;q=0.1, en_US, de;q=2", + ), + ["da", "en-gb;q=0.8", "en;q=0.7", "fr;q=0.7", "*;q=0.1"], + ); + deepStrictEqual(parse(["fr;q=0.5", "de"]), ["de", "fr;q=0.5"]); + // A weight is a qvalue, a parameter is `name=value` + deepStrictEqual(parse("en;q=1e0, fr;q=0.5000, de;x, it;q=1.000"), [ + "it;q=1", + ]); + }); + + test("AcceptLanguage.negotiate()", () => { + const negotiate = (input: string, ...offers: string[]): string | false => + AcceptLanguage.negotiate(offers, input); + const header = "da, en-gb;q=0.8, en;q=0.7"; + + deepStrictEqual(negotiate(header, "en", "da"), "da"); + deepStrictEqual(negotiate(header, "en-US", "en-GB"), "en-GB"); + // A range matches the tag and the tags that it is a prefix of (at a `-`) + deepStrictEqual(negotiate(header, "en"), "en"); + deepStrictEqual(negotiate(header, "en-US"), "en-US"); + deepStrictEqual(negotiate(header, "eng", "de"), false); + deepStrictEqual(negotiate("en-GB", "en"), false); + // The longest matching range gives the weight + deepStrictEqual( + negotiate("en;q=0.2, en-US;q=0.9, fr;q=0.5", "fr", "en-US"), + "en-US", + ); + deepStrictEqual( + negotiate("en-US;q=0.2, en;q=0.9, fr;q=0.5", "fr", "en-US"), + "fr", + ); + // Case-insensitive, and the offer is returned as given + deepStrictEqual(negotiate("EN-us", "en-US"), "en-US"); + // `*` matches any tag, but not those of an excluding range + deepStrictEqual(negotiate("fr;q=0, *", "fr", "de"), "de"); + deepStrictEqual(negotiate("fr;q=0, *", "fr-CA"), false); + deepStrictEqual(negotiate("*;q=0.5, de;q=0.5", "fr", "de"), "de"); + deepStrictEqual(negotiate("*", "fr", "de"), "fr"); + // On a tie: the earlier element, then the earlier offer + deepStrictEqual(negotiate("de, fr", "fr", "de"), "de"); + deepStrictEqual(negotiate("", "fr"), false); + deepStrictEqual(negotiate(header), false); + // Without the header: the first offer + deepStrictEqual(AcceptLanguage.negotiate(["fr", "de"]), "fr"); + deepStrictEqual(AcceptLanguage.negotiate([]), false); + }); + + test("AcceptLanguage.negotiate() (invalid offers)", () => { + for (const offer of ["*", "en_US", "", "en;q=1"]) { + throws( + () => AcceptLanguage.negotiate(["en", offer]), + new TypeError(`Invalid language tag: ${offer}`), + ); + } + }); +}); diff --git a/packages/binden/test/headers/accept.test.ts b/packages/binden/test/headers/accept.test.ts new file mode 100644 index 00000000..e331660f --- /dev/null +++ b/packages/binden/test/headers/accept.test.ts @@ -0,0 +1,239 @@ +import { deepStrictEqual, throws } from "node:assert"; +import { suite, test } from "node:test"; + +import { Accept } from "../../index.js"; + +suite("Accept", () => { + test("constructor", () => { + const accept = new Accept({ + type: "text/html", + parameters: { Level: "1", level: "2", charset: "a b" }, + q_value: 0.5, + }); + deepStrictEqual(accept.type, "text/html"); + // By lowercase name, the first one of a name + deepStrictEqual(accept.parameters, { level: "1", charset: "a b" }); + deepStrictEqual(accept.q_value, 0.5); + deepStrictEqual(accept.toString(), `text/html;level=1;charset="a b";q=0.5`); + + const all = new Accept({ type: "*/*" }); + deepStrictEqual(all.parameters, {}); + deepStrictEqual(all.q_value, null); + deepStrictEqual(all.toString(), "*/*"); + deepStrictEqual( + new Accept({ type: "image/*", q_value: 0 }).toString(), + "image/*;q=0", + ); + }); + + test("constructor (invalid input)", () => { + for (const type of [ + "text", + "*/html", + "text/html/x", + "te xt/html", + "", + "text/", + "/html", + ]) { + throws( + () => new Accept({ type }), + new TypeError(`Invalid media range: ${type}`), + ); + } + for (const q_value of [2, -0.5, 0.0001, Number.NaN]) { + throws( + () => new Accept({ type: "text/html", q_value }), + new TypeError(`\`q_value\` is not a qvalue: ${q_value}`), + ); + } + for (const name of ["q", "Q", "a b", ""]) { + throws( + () => new Accept({ type: "text/html", parameters: { [name]: "1" } }), + new TypeError(`Invalid parameter name: ${name}`), + ); + } + for (const value of ["\n", "a\u0000", "€"]) { + throws( + () => new Accept({ type: "text/html", parameters: { level: value } }), + new TypeError("Invalid value of the parameter level"), + ); + } + }); + + test("Accept.fromString()", () => { + const parse = (input?: string[] | string): string[] => + Accept.fromString(input).map((element) => element.toString()); + + deepStrictEqual(parse(), []); + deepStrictEqual(parse(""), []); + deepStrictEqual(parse([]), []); + // From the greatest weight, in their order on a tie, in lowercase + deepStrictEqual( + parse( + `Text/*;Q=0.3, text/plain;q=0.7, TEXT/plain;FORMAT=flowed, text/plain;format=fixed;q=0.4, */*;q=0.5`, + ), + [ + "text/plain;format=flowed", + "text/plain;q=0.7", + "*/*;q=0.5", + "text/plain;format=fixed;q=0.4", + "text/*;q=0.3", + ], + ); + // Several fields are one list + deepStrictEqual(parse(["text/html;q=0.1", "image/png"]), [ + "image/png", + "text/html;q=0.1", + ]); + // Quoted strings, empty parameters and elements, extensions after the weight + deepStrictEqual( + parse(`text/html ; level="a,b\\"" ; ; q=0.2 ; ext=1 ; ext, ,`), + [`text/html;level="a,b\\"";q=0.2`], + ); + // Malformed elements are skipped + deepStrictEqual( + parse( + `text, */html, a/b/c, text/html;level, text/html;q=2, text/html;q="1", text/html;a="\u0001", ;q=1, image/png`, + ), + ["image/png"], + ); + }); + + test("Accept.negotiate()", () => { + // RFC 9110, section 12.5.1 + const header = `text/*;q=0.3, text/plain;q=0.7, text/plain;format=flowed, text/plain;format=fixed;q=0.4, */*;q=0.5`; + const negotiate = (...offers: string[]): string | false => + Accept.negotiate(offers, header); + + deepStrictEqual(negotiate("text/html", "image/jpeg"), "image/jpeg"); + deepStrictEqual( + negotiate("text/plain;format=fixed", "image/jpeg"), + "image/jpeg", + ); + deepStrictEqual(negotiate("image/jpeg", "text/plain"), "text/plain"); + deepStrictEqual( + negotiate("text/plain", "text/plain;format=flowed"), + "text/plain;format=flowed", + ); + deepStrictEqual( + negotiate("text/plain;format=fixed", "text/html"), + "text/plain;format=fixed", + ); + // Both of the weight of `text/*`: the first offer + deepStrictEqual( + negotiate("text/html;level=3", "text/html"), + "text/html;level=3", + ); + deepStrictEqual(negotiate(), false); + + // Without the header: the first offer + deepStrictEqual(Accept.negotiate(["b/c", "a/b"]), "b/c"); + deepStrictEqual(Accept.negotiate([]), false); + // An empty or invalid header accepts nothing + deepStrictEqual(Accept.negotiate(["a/b"], ""), false); + deepStrictEqual(Accept.negotiate(["a/b"], "garbage"), false); + deepStrictEqual(Accept.negotiate(["a/b"], ["garbage", "a/*"]), "a/b"); + }); + + test("Accept.negotiate() (weights and specificity)", () => { + const negotiate = (input: string, ...offers: string[]): string | false => + Accept.negotiate(offers, input); + + // The weight 0 is not acceptable, also through a less specific range + deepStrictEqual( + negotiate("application/json;q=0, */*", "application/json"), + false, + ); + deepStrictEqual( + negotiate("application/json;q=0, */*", "application/json", "text/html"), + "text/html", + ); + deepStrictEqual(negotiate("text/*;q=0, */*", "text/html"), false); + deepStrictEqual( + negotiate("text/*", "application/json", "text/html"), + "text/html", + ); + // Case-insensitive, and the offer is returned as given + deepStrictEqual(negotiate("TEXT/HTML", "Text/Html"), "Text/Html"); + // On a tie: the more specific range, then the earlier one, then the + // earlier offer + deepStrictEqual( + negotiate("text/*, text/html", "text/plain", "text/html"), + "text/html", + ); + deepStrictEqual( + negotiate("text/html, application/json", "application/json", "text/html"), + "text/html", + ); + deepStrictEqual(negotiate("*/*", "a/b", "c/d"), "a/b"); + deepStrictEqual(negotiate("*/*;q=0.5, c/d;q=0.5", "a/b", "c/d"), "c/d"); + // The most specific range gives the weight (the first one of them) + deepStrictEqual(negotiate("text/html;q=0, text/html", "text/html"), false); + deepStrictEqual( + negotiate("text/html, text/html;q=0", "text/html"), + "text/html", + ); + deepStrictEqual( + negotiate("text/*;q=0.1, text/html;q=0.5, */*;q=0.4", "a/b", "text/html"), + "text/html", + ); + deepStrictEqual( + negotiate("text/*;q=0.1, */*;q=0.4", "a/b", "text/html"), + "a/b", + ); + // The parameters of a range must be those of the offer (their values are + // case-insensitive) + deepStrictEqual(negotiate("text/html;level=1", "text/html"), false); + deepStrictEqual( + negotiate("text/html;level=A", "text/html;LEVEL=a;x=y"), + "text/html;LEVEL=a;x=y", + ); + deepStrictEqual(negotiate("text/html;level=1", "text/html;level=2"), false); + // A range with more parameters is more specific + deepStrictEqual( + negotiate( + "text/html;q=0.9, text/html;level=1;q=0.5, */*;q=0.7", + "text/html;level=1", + "image/png", + ), + "image/png", + ); + deepStrictEqual( + negotiate("*/*;level=1", "text/html;level=1", "a/b"), + "text/html;level=1", + ); + deepStrictEqual( + negotiate( + "text/html;level=1;q=0.5, text/*;q=0.9", + "text/html;level=1", + "text/plain", + ), + "text/plain", + ); + }); + + test("Accept.negotiate() (invalid offers)", () => { + for (const offer of [ + "json", + "text/*", + "*/*", + "*/html", + "text/html;q=1", + "text/html, text/plain", + "", + "a/b/c", + "text/html;level", + ]) { + throws( + () => Accept.negotiate(["a/b", offer], "*/*"), + new TypeError(`Invalid media type: ${offer}`), + ); + // Also without the header + throws( + () => Accept.negotiate(["a/b", offer]), + new TypeError(`Invalid media type: ${offer}`), + ); + } + }); +}); diff --git a/packages/binden/test/request.test.ts b/packages/binden/test/request.test.ts index 3e24931d..318715ab 100644 --- a/packages/binden/test/request.test.ts +++ b/packages/binden/test/request.test.ts @@ -64,6 +64,35 @@ suite("BindenRequest", () => { await serverPromise; }); + test(".accepts() and the like (content negotiation)", () => { + const request = new BindenRequest(new Socket()); + // Without the headers: the first offer + deepStrictEqual( + request.accepts("text/html", "application/json"), + "text/html", + ); + deepStrictEqual(request.acceptsCharsets("utf-8", "latin1"), "utf-8"); + deepStrictEqual(request.acceptsEncodings("gzip", "identity"), "gzip"); + deepStrictEqual(request.acceptsLanguages("en", "fr"), "en"); + deepStrictEqual(request.accepts(), false); + + request.headers = { + accept: "application/json, text/*;q=0.5", + "accept-charset": "latin1;q=0.5, utf-8", + "accept-encoding": "br, gzip;q=0.5", + "accept-language": "fr, en;q=0.5", + }; + deepStrictEqual( + request.accepts("text/html", "application/json"), + "application/json", + ); + deepStrictEqual(request.accepts("image/png"), false); + deepStrictEqual(request.acceptsCharsets("latin1", "utf-8"), "utf-8"); + deepStrictEqual(request.acceptsEncodings("gzip", "br"), "br"); + deepStrictEqual(request.acceptsLanguages("en", "fr"), "fr"); + throws(() => request.accepts("html"), TypeError); + }); + test(".authorization", async () => { const serverPromise = new Promise((resolve, reject) => { server.once("request", (request, response) => { From 6a9ca280789f677f603ed34674a27da982200f96 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:01:59 +0300 Subject: [PATCH 114/675] feat!: find the client behind the trusted proxies in binden A request knew only the protocol of its trusted proxies, read from one `Forwarded` element. request.ip, request.ips and request.host now tell the client, the trusted proxies and the effective host, and `trust_proxy` also takes a function `(address, hop) => boolean`, which is called synchronously (anything but a boolean is a TypeError, never an implicit trust), and an object `{ trust, headers }` to read `X-Forwarded-For`, `X-Forwarded-Proto` and `X-Forwarded-Host` instead of `Forwarded`. The hops are the peer (without the `::ffff:` prefix), then the addresses of the header from right to left: they are walked while they are trusted, and the client is the first one that is not (the leftmost one when all of them are). `unknown` and obfuscated nodes stop the walk with an unknown client; an invalid element on the way also makes the protocol and the host those of the connection, so that a client cannot take the place of a proxy. The protocol and the host come from the element of the farthest trusted proxy, or from one X-Forwarded-Proto and one X-Forwarded-Host set by a trusted peer (a duplicate or a list is ambiguous). request.URL takes the same protocol and host, after the authority of an absolute-form request-target. BREAKING CHANGE: Behind trusted proxies, request.URL takes the host that they forward, and a trusted Forwarded element with an invalid `for`, `proto` or `host` gives the protocol of the connection (its `proto` was used before). ITrustProxy also has the function and object forms. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/proxy.ts | 298 ++++++++++++ packages/binden/src/request.ts | 159 ++++--- packages/binden/test/proxy.test.ts | 665 +++++++++++++++++++++++++++ packages/binden/test/request.test.ts | 49 -- 4 files changed, 1068 insertions(+), 103 deletions(-) create mode 100644 packages/binden/src/proxy.ts create mode 100644 packages/binden/test/proxy.test.ts diff --git a/packages/binden/src/proxy.ts b/packages/binden/src/proxy.ts new file mode 100644 index 00000000..3dda860b --- /dev/null +++ b/packages/binden/src/proxy.ts @@ -0,0 +1,298 @@ +import type { IncomingHttpHeaders } from "node:http"; +import { SocketAddress, isIPv4, isIPv6 } from "node:net"; + +import Forwarded from "./headers/forwarded.js"; + +/** + * Whether the address of a hop (`0` is the peer, then the addresses of the + * header from right to left) is a trusted proxy: called synchronously, it + * must return a boolean + */ +export type ITrustFn = (address: string, hop: number) => boolean; + +/** + * The headers of the trusted proxies: `Forwarded` (RFC 7239), or + * `X-Forwarded-For` with `X-Forwarded-Proto` and `X-Forwarded-Host` + */ +export type IProxyHeaders = "forwarded" | "x-forwarded"; + +/** + * The proxies in front of the server that are trusted: none (`false`), all of + * them (`true`), the given number of the nearest hops, or those that a + * function accepts; with the headers that they send (`forwarded` by default) + */ +export type ITrustProxy = + | ITrustFn + | boolean + | number + | { + trust: ITrustFn | boolean | number; + headers?: IProxyHeaders | undefined; + }; + +/** A checked `trust_proxy` option, with its headers */ +export interface IProxyTrust { + readonly trust: ITrustFn | boolean | number; + readonly headers: IProxyHeaders; +} + +/** What the trusted proxies tell about the client */ +export interface IProxyResult { + /** The address of the client, or `null` when it is unknown */ + readonly ip: string | null; + /** The client (unless unknown) and the trusted proxies, left to right */ + readonly ips: readonly string[]; + /** The protocol from a trusted proxy, or `null` for the connection's */ + readonly protocol: "http" | "https" | null; + /** The host from a trusted proxy, or `null` for the request's own */ + readonly host: string | null; +} + +/** A hop: its address (`null` for `unknown` or an obfuscated one) */ +interface IHop { + readonly address: string | null; + /** The protocol and the host that the proxy received (`Forwarded`) */ + readonly protocol: string | null; + readonly host: string | null; +} + +/** `nodename [ ":" node-port ]` (RFC 7239, section 6) */ +const node = + /^(?:\[(?[^\]]*)\]|(?[^:[\]]+))(?::(?:\d{1,5}|_[\w.-]+))?$/u; + +/** A URI scheme (RFC 3986, section 3.1) */ +const scheme = /^[A-Za-z][\d+.A-Za-z-]*$/u; + +/** An IPv4-mapped IPv6 address */ +const mapped = /^::ffff:(?\d{1,3}(?:\.\d{1,3}){3})$/iu; + +/** + * An IPv6 address in its canonical form: in lowercase and compressed (with its + * zone), and as IPv4 when it is an IPv4-mapped address + */ +function canonical(address: string): string { + const [ip = "", ...zone] = address.split("%"); + const { address: v6 } = new SocketAddress({ address: ip, family: "ipv6" }); + const v4 = mapped.exec(v6)?.groups?.v4; + if (typeof v4 === "string") { + return v4; + } + return zone.length > 0 ? `${v6}%${zone.join("%")}` : v6; +} + +/** + * A node of a proxy header: an IP address (`[v6]`, `v4`, or a bare IPv6 + * address, with an optional port) as `{ address }`, `unknown` or an + * obfuscated identifier (`_x`) as `{ address: null }`, `null` when invalid + */ +export function parseNode(value: string): { address: string | null } | null { + if (isIPv6(value)) { + return { address: canonical(value) }; + } + const { v6, name = "" } = node.exec(value)?.groups ?? {}; + if (typeof v6 === "string") { + return isIPv6(v6) ? { address: canonical(v6) } : null; + } else if (isIPv4(name)) { + return { address: name }; + } else if (/^unknown$/iu.test(name) || /^_[\w.-]+$/u.test(name)) { + return { address: null }; + } + return null; +} + +/** + * Whether a value is `uri-host [ ":" port ]` (RFC 9110, section 7.2): nothing + * that ends the authority of a URL, nor userinfo + */ +export function isAuthority(value: string): boolean { + return !/[\s/?#@\\]/u.test(value) && URL.canParse(`http://${value}`); +} + +/** + * Throws a `TypeError` unless the result of a synchronous callback is a + * boolean: a promise (whose rejection is handled) or any other value is a + * configuration error, never an implicit `true` + */ +export function strictBoolean(result: unknown, name: string): boolean { + if (typeof result === "boolean") { + return result; + } else if ( + typeof (result as { then?: unknown } | null)?.then === "function" + ) { + Promise.resolve(result).catch(() => null); + } + throw new TypeError(`\`${name}\` has not returned a boolean`); +} + +/** `true`, `false`, a function, or a non-negative safe integer */ +function checkTrust(trust: unknown): ITrustFn | boolean | number { + if ( + typeof trust === "boolean" || + (typeof trust === "number" && Number.isSafeInteger(trust) && trust >= 0) + ) { + return trust; + } else if (typeof trust === "number") { + throw new RangeError("`trust_proxy` is not a non-negative integer"); + } else if (typeof trust !== "function") { + throw new TypeError("`trust_proxy` is not a boolean, number or function"); + } + return trust as ITrustFn; +} + +/** + * The `trust_proxy` option with its headers, checked: a `TypeError` (or a + * `RangeError` for a number that is not a non-negative safe integer) otherwise + */ +export function checkTrustProxy(value: unknown): IProxyTrust { + if (typeof value !== "object" || value === null) { + return Object.freeze({ trust: checkTrust(value), headers: "forwarded" }); + } + const { trust, headers = "forwarded" } = value as Record; + if (headers !== "forwarded" && headers !== "x-forwarded") { + throw new TypeError( + '`trust_proxy.headers` is not "forwarded" or "x-forwarded"', + ); + } + return Object.freeze({ trust: checkTrust(trust), headers }); +} + +/** The trust of a hop, as a function */ +function trusting(trust: ITrustFn | boolean | number): ITrustFn { + if (typeof trust === "function") { + return (address, hop) => strictBoolean(trust(address, hop), "trust_proxy"); + } else if (typeof trust === "number") { + return (_address, hop) => hop < trust; + } + return () => trust; +} + +/** The elements of `Forwarded` as hops, `null` for an invalid one */ +function forwardedHops(header?: string): (IHop | null)[] { + return Forwarded.parse(header).map((element) => { + const found = element === null ? null : parseNode(element.for); + if ( + element === null || + found === null || + (element.proto !== null && !scheme.test(element.proto)) || + (element.host !== null && !isAuthority(element.host)) + ) { + return null; + } + const protocol = element.proto?.toLowerCase() ?? null; + return { address: found.address, protocol, host: element.host }; + }); +} + +/** The addresses of `X-Forwarded-For` as hops, `null` for an invalid one */ +function xForwardedHops(header?: string[] | string): (IHop | null)[] { + // Field lines are joined with `,` (also those of an array) + return String(header ?? "") + .split(",") + .map((element) => element.trim()) + .filter((element) => element !== "") + .map((element) => { + const found = parseNode(element); + return found === null + ? null + : { address: found.address, protocol: null, host: null }; + }); +} + +/** + * The protocol and the host that the trusted peer has set: one field line of + * one value each. When one of them is duplicated, a list or malformed, those + * of the request are used for both. + */ +function xForwardedOrigin( + distinct: NodeJS.Dict, +): Pick { + const none = { protocol: null, host: null }; + const fields = ["x-forwarded-proto", "x-forwarded-host"].map( + (name) => distinct[name] ?? [], + ); + if ( + fields.some( + (values) => + values.length > 1 || values.some((value) => value.includes(",")), + ) + ) { + return none; + } + const [[proto = null] = [], [host = null] = []] = fields; + if ( + (proto !== null && !scheme.test(proto)) || + (host !== null && !isAuthority(host)) + ) { + return none; + } + return { protocol: proto?.toLowerCase() ?? null, host }; +} + +/** + * What the trusted proxies tell about the client. The hops are the peer (hop + * 0), then the addresses of the header from right to left (every proxy + * appends the address of its own client): they are walked while they are + * trusted. The client is the first one that is not, or the leftmost one when + * all of them are. The walk stops on `unknown` or an obfuscated identifier + * (an unknown client), and on an invalid element (an unknown client, and the + * protocol and host of the request). + * + * The protocol and the host are those of the element of the farthest trusted + * proxy (`Forwarded`), or those that a trusted peer has set (`X-Forwarded-*`). + */ +export function resolveProxy( + peer: string | undefined, + { trust, headers }: IProxyTrust, + fields: IncomingHttpHeaders, + distinct: NodeJS.Dict, +): IProxyResult { + const first = typeof peer === "string" ? parseNode(peer) : null; + if (typeof first?.address !== "string") { + return { ip: null, ips: [], protocol: null, host: null }; + } + const trusts = trusting(trust); + const addresses = [first.address]; + if (!trusts(first.address, 0)) { + return { ip: first.address, ips: addresses, protocol: null, host: null }; + } + + const forwarded = headers === "forwarded"; + const hops = forwarded + ? forwardedHops(fields.forwarded) + : xForwardedHops(fields["x-forwarded-for"]); + let origin = forwarded + ? { protocol: null, host: null } + : xForwardedOrigin(distinct); + const result = (ip: string | null): IProxyResult => { + const { protocol, host } = origin; + return { + ip, + ips: addresses.toReversed(), + protocol: protocol === "http" || protocol === "https" ? protocol : null, + host, + }; + }; + + for (let hop = 1; hop <= hops.length; hop += 1) { + // Appended by the proxy of the previous hop, which is trusted + const element = hops[hops.length - hop]; + if (element === null) { + return { + ip: null, + ips: addresses.toReversed(), + protocol: null, + host: null, + }; + } else if (forwarded) { + origin = element; + } + if (element.address === null) { + return result(null); + } + addresses.push(element.address); + if (!trusts(element.address, hop)) { + return result(element.address); + } + } + return result(addresses[addresses.length - 1]); +} diff --git a/packages/binden/src/request.ts b/packages/binden/src/request.ts index 48efef8f..45be753a 100644 --- a/packages/binden/src/request.ts +++ b/packages/binden/src/request.ts @@ -15,14 +15,17 @@ import Cookie from "./headers/cookie.js"; import Forwarded from "./headers/forwarded.js"; import IfModifiedSince from "./headers/if-modified-since.js"; import Range from "./headers/range.js"; +import { + type IProxyResult, + type ITrustProxy, + checkTrustProxy, + isAuthority, + resolveProxy, +} from "./proxy.js"; -export type IProtocol = "http:" | "https:"; +export type { IProxyHeaders, ITrustFn, ITrustProxy } from "./proxy.js"; -/** - * The proxies in front of the server that are trusted: none (`false`), all of - * them (`true`) or the given number of the nearest ones - */ -export type ITrustProxy = boolean | number; +export type IProtocol = "http:" | "https:"; export class BindenRequest extends IncomingMessage { #accept_encoding?: readonly AcceptEncoding[]; @@ -34,6 +37,7 @@ export class BindenRequest extends IncomingMessage { #forwarded?: readonly Forwarded[]; #id?: string; #if_modified_since?: IfModifiedSince | null; + #proxy?: IProxyResult; #range?: readonly Range[]; #query?: ParsedUrlQuery; @@ -146,28 +150,52 @@ export class BindenRequest extends IncomingMessage { } /** - * The protocol of the connection or, from trusted proxies (see - * `trust_proxy`), the one of the `Forwarded` element of the outermost of them + * The effective host of the request (`hostname[:port]`, in lowercase, without + * the default port of the protocol): the authority of a request-target in + * absolute-form, otherwise the host from a trusted proxy (see `trust_proxy`), + * otherwise `Host`. `null` without any, and a `BindenError` (400) for an + * invalid one. + */ + public get host(): string | null { + const { authority } = BindenRequest.#split(this.url ?? "/"); + const origin = this.#origin(authority); + return origin === null ? null : new URL(origin).host; + } + + /** + * The address of the client: the peer (without the `::ffff:` prefix of an + * IPv4-mapped address) or, behind trusted proxies (see `trust_proxy`), the + * first address of their header that is not trusted (the leftmost one when + * all of them are). `null` when it is unknown (e.g. `for=unknown`, or an + * invalid element on the way). + */ + public get ip(): string | null { + return this.#resolve().ip; + } + + /** The address of the client (unless unknown) and of the trusted proxies, left to right */ + public get ips(): readonly string[] { + return [...this.#resolve().ips]; + } + + /** + * The protocol of the connection or, behind trusted proxies (see + * `trust_proxy`), the one that they have received: the `proto` of the + * `Forwarded` element of the farthest trusted proxy, or the + * `X-Forwarded-Proto` that a trusted peer has set */ public get protocol(): IProtocol { - const tls: IProtocol = - this.socket instanceof TLSSocket ? "https:" : "http:"; - const trust = this.trust_proxy; - // In their positions: an element that is not valid is not skipped - const forwarded = Forwarded.parse(this.headers.forwarded); - if (trust === false || trust === 0 || forwarded.length === 0) { - return tls; + const { protocol } = this.#resolve(); + if (protocol === null) { + return this.socket instanceof TLSSocket ? "https:" : "http:"; } - // Every proxy appends its element: the client controls the leftmost ones - const index = trust === true ? 0 : Math.max(0, forwarded.length - trust); - const proto = forwarded[index]?.proto?.toLowerCase(); - return proto === "https" ? "https:" : proto === "http" ? "http:" : tls; + return `${protocol}:`; } /** - * The proxies whose `Forwarded` elements are trusted (none by default). - * `Binden` sets it from its `trust_proxy` option; with `http.createServer()`, - * override it in a subclass. + * The proxies in front of the server that are trusted, and their headers + * (none by default). `Binden` sets it from its `trust_proxy` option; with + * `http.createServer()`, override it in a subclass. */ // eslint-disable-next-line class-methods-use-this -- overridden by subclasses protected get trust_proxy(): ITrustProxy { @@ -207,55 +235,78 @@ export class BindenRequest extends IncomingMessage { * 3.2). A `400 Bad Request` error without a valid one. */ public get URL(): URL { - const { authority, path, query } = BindenRequest.#target( - this.url ?? "/", - this.method ?? "GET", - ); - const host = authority ?? this.headers.host ?? ""; - const base = `${this.protocol}//${host}`; - // `uri-host [ ":" port ]`: nothing that ends the authority, nor userinfo - if (/[\s/?#@\\]/u.test(host) || !URL.canParse(base)) { + const target = this.url ?? "/"; + const { authority, rest } = BindenRequest.#split(target); + const { path, query } = + target === "*" && this.method === "OPTIONS" + ? { path: "*", query: "" } + : BindenRequest.#target(rest); + const origin = this.#origin(authority); + if (origin === null) { throw new BindenError(400); } - const url = new URL(base); + const url = new URL(origin); // Set as a path: `//evil.test/x` is a path, not the authority of the URL url.pathname = path; url.search = query === "" ? "" : `?${query}`; return url; } + /** What the trusted proxies tell about the client, found once */ + #resolve(): IProxyResult { + this.#proxy ??= resolveProxy( + this.socket.remoteAddress, + checkTrustProxy(this.trust_proxy), + this.headers, + this.headersDistinct, + ); + return this.#proxy; + } + /** - * The authority (of the absolute-form), the path and the query of a - * request-target, which must be in origin-form (or absolute-form with `http` - * or `https`, or `*` for `OPTIONS`), without a fragment, a backslash, an - * invalid percent-encoding, or dot-segments (which the URL parser would - * remove, so that the request would be routed to another path). + * The protocol and the effective authority of the request (see `host`), or + * `null` without an authority. A `BindenError` (400) for an invalid one (not + * `uri-host [ ":" port ]`, e.g. with userinfo). */ - static #target( - target: string, - method: string, - ): { authority: string | null; path: string; query: string } { - if (target === "*" && method === "OPTIONS") { - return { authority: null, path: "*", query: "" }; + #origin(target: string | null): string | null { + const authority = target ?? this.#resolve().host ?? this.headers.host; + if (typeof authority === "undefined") { + return null; + } else if (!isAuthority(authority)) { + throw new BindenError(400); } + return `${this.protocol}//${authority}`; + } - let authority: string | null = null; - let rest = target; + /** + * The authority of a request-target in absolute-form (`null` otherwise) and + * the rest of it: a `BindenError` (400) for another scheme than `http` and + * `https`, or an empty authority + */ + static #split(target: string): { authority: string | null; rest: string } { const absolute = /^(?[a-z][\d+.a-z-]*):\/\/(?[^/?]*)(?.*)$/iu.exec( target, )?.groups; - if (absolute) { - const { scheme = "", host = "", tail = "" } = absolute; - // The authority is then checked as `Host` (e.g. for userinfo) - if (!["http", "https"].includes(scheme.toLowerCase()) || host === "") { - throw new BindenError(400); - } - authority = host; - // An empty path is `/` - rest = tail.startsWith("/") ? tail : `/${tail}`; + if (!absolute) { + return { authority: null, rest: target }; + } + const { scheme = "", host = "", tail = "" } = absolute; + // The authority is then checked as `Host` (e.g. for userinfo) + if (!["http", "https"].includes(scheme.toLowerCase()) || host === "") { + throw new BindenError(400); } + // An empty path is `/` + return { authority: host, rest: tail.startsWith("/") ? tail : `/${tail}` }; + } + /** + * The path and the query of a request-target (without its authority), which + * must be in origin-form, without a fragment, a backslash, an invalid + * percent-encoding, or dot-segments (which the URL parser would remove, so + * that the request would be routed to another path). + */ + static #target(rest: string): { path: string; query: string } { if ( !rest.startsWith("/") || /[#\\]/u.test(rest) || @@ -270,6 +321,6 @@ export class BindenRequest extends IncomingMessage { if (path.split("/").some((part) => /^(?:\.|%2e){1,2}$/iu.test(part))) { throw new BindenError(400); } - return { authority, path, query }; + return { path, query }; } } diff --git a/packages/binden/test/proxy.test.ts b/packages/binden/test/proxy.test.ts new file mode 100644 index 00000000..4cf9db57 --- /dev/null +++ b/packages/binden/test/proxy.test.ts @@ -0,0 +1,665 @@ +import { deepStrictEqual, ok, throws } from "node:assert"; +import { createServer, type IncomingHttpHeaders } from "node:http"; +import { type AddressInfo, Socket, connect } from "node:net"; +import { TLSSocket } from "node:tls"; +import { after, suite, test } from "node:test"; + +import { BindenError, BindenRequest, type ITrustProxy } from "../index.js"; + +interface IFixture { + /** The address of the peer (none: `undefined`) */ + peer?: string | null; + tls?: boolean; + url?: string; + method?: string; +} + +const sockets: Socket[] = []; + +/** A request from `peer` with the field lines, behind the trusted proxies */ +function create( + trust: ITrustProxy, + fields: [string, string][] = [], + { peer = "10.0.0.1", tls = false, url = "/", method = "GET" }: IFixture = {}, +): BindenRequest { + class Trusting extends BindenRequest { + // eslint-disable-next-line class-methods-use-this -- a fixture + protected override get trust_proxy(): ITrustProxy { + return trust; + } + } + const socket = tls ? new TLSSocket(new Socket()) : new Socket(); + sockets.push(socket); + if (peer !== null) { + Object.defineProperty(socket, "remoteAddress", { value: peer }); + } + const request = new Trusting(socket); + request.url = url; + request.method = method; + // As Node.js does: the field lines of a name joined, and also kept apart + const headers: IncomingHttpHeaders = {}; + const distinct: NodeJS.Dict = {}; + for (const [name, value] of fields) { + const key = name.toLowerCase(); + const values = distinct[key] ?? []; + values.push(value); + distinct[key] = values; + headers[key] = values.join(", "); + } + request.headers = headers; + request.headersDistinct = distinct; + return request; +} + +/** The address of the client, the addresses, the protocol and the host */ +function describe( + request: BindenRequest, +): [string | null, readonly string[], string, string | null] { + return [request.ip, request.ips, request.protocol, request.host]; +} + +/** Throws a `BindenError` with the status 400 */ +function badRequest(error: unknown): boolean { + return error instanceof BindenError && error.status === 400; +} + +const host: [string, string] = ["Host", "origin.test"]; + +suite("Trusted proxies", () => { + test("no trusted proxy", () => { + const fields: [string, string][] = [ + host, + ["Forwarded", "for=1.1.1.1;proto=https;host=evil.test"], + ["X-Forwarded-For", "1.1.1.1"], + ["X-Forwarded-Proto", "https"], + ["X-Forwarded-Host", "evil.test"], + ]; + for (const trust of [false, 0, (): boolean => false]) { + deepStrictEqual( + describe(create(trust, fields)), + ["10.0.0.1", ["10.0.0.1"], "http:", "origin.test"], + String(trust), + ); + deepStrictEqual( + describe(create({ trust, headers: "x-forwarded" }, fields)), + ["10.0.0.1", ["10.0.0.1"], "http:", "origin.test"], + ); + } + // The peer without the prefix of an IPv4-mapped address + deepStrictEqual( + create(false, [], { peer: "::ffff:10.0.0.2" }).ip, + "10.0.0.2", + ); + deepStrictEqual(create(false, [], { peer: "::FFFF:a00:3" }).ip, "10.0.0.3"); + deepStrictEqual( + create(false, [], { peer: "2001:DB8:0::1" }).ip, + "2001:db8::1", + ); + // An unknown peer (e.g. a closed connection) + deepStrictEqual( + describe( + create(true, [host, ["Forwarded", "for=1.1.1.1;proto=https"]], { + peer: null, + }), + ), + [null, [], "http:", "origin.test"], + ); + }); + + test("the number of trusted hops (`Forwarded`)", () => { + // The client sent the first element, the nearest proxy (the peer) the last + const fields: [string, string][] = [ + host, + [ + "Forwarded", + "for=1.1.1.1;proto=https;host=a.test, for=2.2.2.2;proto=http;host=b.test", + ], + ]; + // The peer is trusted: its element (the last one) gives the client + deepStrictEqual(describe(create(1, fields)), [ + "2.2.2.2", + ["2.2.2.2", "10.0.0.1"], + "http:", + "b.test", + ]); + deepStrictEqual(describe(create(2, fields)), [ + "1.1.1.1", + ["1.1.1.1", "2.2.2.2", "10.0.0.1"], + "https:", + "a.test", + ]); + // All of them are trusted: the leftmost one + for (const trust of [3, 5, true, (): boolean => true]) { + deepStrictEqual(describe(create(trust, fields)), [ + "1.1.1.1", + ["1.1.1.1", "2.2.2.2", "10.0.0.1"], + "https:", + "a.test", + ]); + } + // Without the header: the peer and the connection + deepStrictEqual(describe(create(true, [host], { tls: true })), [ + "10.0.0.1", + ["10.0.0.1"], + "https:", + "origin.test", + ]); + // The `Forwarded` field lines are one list + deepStrictEqual( + create(2, [ + ["Forwarded", "for=1.1.1.1"], + ["Forwarded", "for=2.2.2.2"], + ]).ips, + ["1.1.1.1", "2.2.2.2", "10.0.0.1"], + ); + }); + + test("a trust function", () => { + const calls: [string, number][] = []; + const trust = (address: string, hop: number): boolean => { + calls.push([address, hop]); + return address.startsWith("10."); + }; + const fields: [string, string][] = [ + ["X-Forwarded-For", "3.3.3.3, 1.1.1.1, 10.0.0.2"], + ]; + const request = create({ trust, headers: "x-forwarded" }, fields); + deepStrictEqual(request.ip, "1.1.1.1"); + deepStrictEqual(request.ips, ["1.1.1.1", "10.0.0.2", "10.0.0.1"]); + // Found once + deepStrictEqual(request.protocol, "http:"); + deepStrictEqual(calls, [ + ["10.0.0.1", 0], + ["10.0.0.2", 1], + ["1.1.1.1", 2], + ]); + // Called even for the leftmost address + calls.length = 0; + deepStrictEqual( + create(trust, [["Forwarded", "for=10.0.0.2"]]).ip, + "10.0.0.2", + ); + deepStrictEqual(calls, [ + ["10.0.0.1", 0], + ["10.0.0.2", 1], + ]); + }); + + test("a trust function that does not return a boolean", async () => { + const unhandled: unknown[] = []; + const listener = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on("unhandledRejection", listener); + try { + const rejected = Promise.reject(new Error("Rejected")); + let then = false; + const thenable = { + then(_resolve: unknown, reject: (reason: unknown) => void): void { + then = true; + reject(new Error("Rejected")); + }, + }; + for (const result of [rejected, thenable, 1, "true", {}, null]) { + const trust = (() => result) as unknown as ITrustProxy; + // A configuration error: a TypeError (500), never an implicit `true` + const error = new TypeError("`trust_proxy` has not returned a boolean"); + const request = create(trust, [host]); + throws(() => request.ip, error); + throws(() => request.protocol, error); + throws(() => request.URL, error); + throws(() => request.host, error); + } + await new Promise((resolve) => { + setImmediate(resolve); + }); + ok(then); + deepStrictEqual(unhandled, []); + } finally { + process.off("unhandledRejection", listener); + } + }); + + test("an invalid `trust_proxy`", () => { + const invalid: [unknown, Error][] = [ + [-1, new RangeError("`trust_proxy` is not a non-negative integer")], + [1.5, new RangeError("`trust_proxy` is not a non-negative integer")], + [ + Number.NaN, + new RangeError("`trust_proxy` is not a non-negative integer"), + ], + [2 ** 53, new RangeError("`trust_proxy` is not a non-negative integer")], + [ + "1", + new TypeError("`trust_proxy` is not a boolean, number or function"), + ], + [ + null, + new TypeError("`trust_proxy` is not a boolean, number or function"), + ], + [{}, new TypeError("`trust_proxy` is not a boolean, number or function")], + [ + { trust: {} }, + new TypeError("`trust_proxy` is not a boolean, number or function"), + ], + [ + { trust: -1 }, + new RangeError("`trust_proxy` is not a non-negative integer"), + ], + [ + { trust: 1, headers: "X-Forwarded" }, + new TypeError( + '`trust_proxy.headers` is not "forwarded" or "x-forwarded"', + ), + ], + ]; + for (const [trust, error] of invalid) { + throws( + () => create(trust as ITrustProxy).ip, + error, + JSON.stringify(trust), + ); + } + // `headers` defaults to `forwarded` + deepStrictEqual( + create({ trust: 1 }, [["Forwarded", "for=1.1.1.1"]]).ip, + "1.1.1.1", + ); + deepStrictEqual( + create({ trust: 1, headers: "forwarded" }, [ + ["X-Forwarded-For", "1.1.1.1"], + ]).ip, + "10.0.0.1", + ); + }); + + test("the addresses of `Forwarded`", () => { + // The address, and whether the element is valid (its `proto` is used) + const ip = (value: string): [string | null, boolean] => { + const request = create(true, [["Forwarded", `for=${value};proto=https`]]); + return [request.ip, request.secure]; + }; + + deepStrictEqual(ip("1.2.3.4"), ["1.2.3.4", true]); + deepStrictEqual(ip(`"1.2.3.4:8080"`), ["1.2.3.4", true]); + deepStrictEqual(ip(`"1.2.3.4:_port"`), ["1.2.3.4", true]); + deepStrictEqual(ip(`"[2001:DB8:0::1]:4711"`), ["2001:db8::1", true]); + deepStrictEqual(ip(`"[2001:db8::1]"`), ["2001:db8::1", true]); + deepStrictEqual(ip(`"[::ffff:1.2.3.4]"`), ["1.2.3.4", true]); + deepStrictEqual(ip(`"[fe80::1%eth0]"`), ["fe80::1%eth0", true]); + // Unquoted and bare, as some proxies send them + deepStrictEqual(ip("2001:db8::1"), ["2001:db8::1", true]); + deepStrictEqual(ip("[2001:db8::1]:80"), ["2001:db8::1", true]); + // Valid, but not addresses: the client is unknown + for (const value of [ + "unknown", + "UNKNOWN", + `"unknown:80"`, + "_hidden", + `"_a.b-c:_p"`, + ]) { + deepStrictEqual(ip(value), [null, true], value); + } + // Invalid + for (const value of [ + "x", + `""`, + "1.2.3", + "01.2.3.4", + "256.1.1.1", + `"1.2.3.4:"`, + `"1.2.3.4:123456"`, + `"[1.2.3.4]"`, + `"[2001:db8::1"`, + `"[::1]x"`, + "_", + `"_a b"`, + "unknownx", + ]) { + deepStrictEqual(ip(value), [null, false], value); + } + }); + + test("unknown and invalid elements (`Forwarded`)", () => { + const fields = (value: string): [string, string][] => [ + host, + ["Forwarded", value], + ]; + // The walk stops on an unknown client, with the protocol and host of the + // element of the farthest trusted proxy + deepStrictEqual( + describe( + create( + true, + fields( + "for=1.1.1.1, for=unknown;proto=https;host=a.test, for=2.2.2.2", + ), + ), + ), + [null, ["2.2.2.2", "10.0.0.1"], "https:", "a.test"], + ); + deepStrictEqual( + describe( + create(true, fields("for=1.1.1.1, for=_x;proto=https, for=2.2.2.2")), + ), + [null, ["2.2.2.2", "10.0.0.1"], "https:", "origin.test"], + ); + // An element without `for` is of an unknown client + deepStrictEqual(describe(create(1, fields("proto=https"))), [ + null, + ["10.0.0.1"], + "https:", + "origin.test", + ]); + // Empty elements are not elements + deepStrictEqual( + describe(create(1, fields("for=1.1.1.1;proto=https, , "))), + ["1.1.1.1", ["1.1.1.1", "10.0.0.1"], "https:", "origin.test"], + ); + // An invalid element on the way: an unknown client, and the protocol and + // host of the request (its position is kept: an element of the client + // does not take its place) + for (const invalid of [ + "garbage", + "for=x;proto=https", + `for=""`, + `for="a,b"`, + "for=1.1.1.1;for=2.2.2.2", + "for=2.2.2.2;proto=1x", + `for=2.2.2.2;host="a b"`, + "for=2.2.2.2;host=a@b.test", + ]) { + deepStrictEqual( + describe( + create( + true, + fields(`for=1.1.1.1;proto=https;host=a.test, ${invalid}`), + ), + ), + [null, ["10.0.0.1"], "http:", "origin.test"], + invalid, + ); + deepStrictEqual( + describe( + create( + true, + fields( + `for=1.1.1.1;proto=https, ${invalid}, for=2.2.2.2;host=b.test`, + ), + ), + ), + [null, ["2.2.2.2", "10.0.0.1"], "http:", "origin.test"], + invalid, + ); + } + // ... but not one beyond the client + deepStrictEqual( + describe( + create(1, fields("garbage, for=1.1.1.1;proto=https;host=a.test")), + ), + ["1.1.1.1", ["1.1.1.1", "10.0.0.1"], "https:", "a.test"], + ); + // Another scheme than `http` and `https`: the protocol of the connection + deepStrictEqual( + describe( + create(1, fields("for=1.1.1.1;proto=ftp;host=a.test"), { tls: true }), + ), + ["1.1.1.1", ["1.1.1.1", "10.0.0.1"], "https:", "a.test"], + ); + deepStrictEqual( + create(1, fields("for=1.1.1.1;proto=HTTPS")).protocol, + "https:", + ); + // Over TLS, a trusted `http` is `http:`, and an element without `proto` + // keeps `https:` + deepStrictEqual( + create(1, fields("for=1.1.1.1;proto=http"), { tls: true }).protocol, + "http:", + ); + deepStrictEqual( + create(1, fields("for=1.1.1.1"), { tls: true }).protocol, + "https:", + ); + }); + + test("`X-Forwarded-For`, `X-Forwarded-Proto` and `X-Forwarded-Host`", () => { + const trust = (hops: ITrustProxy): ITrustProxy => ({ + trust: hops as boolean, + headers: "x-forwarded", + }); + const fields: [string, string][] = [ + host, + ["X-Forwarded-For", "1.1.1.1, 2.2.2.2"], + ["X-Forwarded-Proto", "https"], + ["X-Forwarded-Host", "a.test"], + // The families of headers are not mixed + ["Forwarded", "for=3.3.3.3;proto=http;host=b.test"], + ]; + deepStrictEqual(describe(create(trust(1), fields)), [ + "2.2.2.2", + ["2.2.2.2", "10.0.0.1"], + "https:", + "a.test", + ]); + deepStrictEqual(describe(create(trust(true), fields)), [ + "1.1.1.1", + ["1.1.1.1", "2.2.2.2", "10.0.0.1"], + "https:", + "a.test", + ]); + // The protocol and the host that the trusted peer has set, without + // `X-Forwarded-For`, or one of them + deepStrictEqual( + describe(create(trust(1), [host, ["X-Forwarded-Proto", "HTTPS"]])), + ["10.0.0.1", ["10.0.0.1"], "https:", "origin.test"], + ); + deepStrictEqual( + describe(create(trust(1), [host, ["X-Forwarded-Host", "A.test:8443"]])), + ["10.0.0.1", ["10.0.0.1"], "http:", "a.test:8443"], + ); + // `unknown`: an unknown client, but the protocol and host of the peer + deepStrictEqual( + describe( + create(trust(true), [ + ...fields.slice(0, 1), + ["X-Forwarded-For", "1.1.1.1, unknown"], + ...fields.slice(2), + ]), + ), + [null, ["10.0.0.1"], "https:", "a.test"], + ); + // Empty elements are not elements, several field lines are one list + deepStrictEqual( + create(trust(true), [ + ["X-Forwarded-For", "1.1.1.1, , [2001:db8::1]:80"], + ["X-Forwarded-For", "2.2.2.2:8080"], + ]).ips, + ["1.1.1.1", "2001:db8::1", "2.2.2.2", "10.0.0.1"], + ); + }); + + test("`X-Forwarded-*` (ambiguous or invalid)", () => { + const trust: ITrustProxy = { trust: true, headers: "x-forwarded" }; + const origin = (...fields: [string, string][]): [string, string | null] => { + const request = create(trust, [ + host, + ["X-Forwarded-For", "1.1.1.1"], + ...fields, + ]); + return [request.protocol, request.host]; + }; + deepStrictEqual( + origin(["X-Forwarded-Proto", "https"], ["X-Forwarded-Host", "a.test"]), + ["https:", "a.test"], + ); + // A duplicated field or a list: the protocol and the host of the request + for (const fields of [ + [ + ["X-Forwarded-Proto", "https"], + ["X-Forwarded-Proto", "https"], + ["X-Forwarded-Host", "a.test"], + ], + [ + ["X-Forwarded-Proto", "https, http"], + ["X-Forwarded-Host", "a.test"], + ], + [ + ["X-Forwarded-Proto", "https"], + ["X-Forwarded-Host", "a.test"], + ["X-Forwarded-Host", "b.test"], + ], + [ + ["X-Forwarded-Proto", "https"], + ["X-Forwarded-Host", "a.test,b.test"], + ], + // Malformed + [ + ["X-Forwarded-Proto", "1x"], + ["X-Forwarded-Host", "a.test"], + ], + [ + ["X-Forwarded-Proto", ""], + ["X-Forwarded-Host", "a.test"], + ], + [ + ["X-Forwarded-Proto", "https"], + ["X-Forwarded-Host", "a.test/x"], + ], + [ + ["X-Forwarded-Proto", "https"], + ["X-Forwarded-Host", ""], + ], + ] as [string, string][][]) { + deepStrictEqual( + origin(...fields), + ["http:", "origin.test"], + JSON.stringify(fields), + ); + } + // Another scheme: the protocol of the connection + deepStrictEqual( + origin(["X-Forwarded-Proto", "ftp"], ["X-Forwarded-Host", "a.test"]), + ["http:", "a.test"], + ); + // An invalid address on the way: an unknown client, and the protocol and + // host of the request + for (const value of ["1.1.1.1, x", "1.1.1.1, 2.2.2.2:", "_"]) { + const request = create(trust, [ + host, + ["X-Forwarded-For", value], + ["X-Forwarded-Proto", "https"], + ["X-Forwarded-Host", "a.test"], + ]); + deepStrictEqual( + describe(request), + [null, ["10.0.0.1"], "http:", "origin.test"], + value, + ); + } + }); + + test(".URL and .host behind trusted proxies", () => { + const fields: [string, string][] = [ + host, + ["Forwarded", "for=1.1.1.1;proto=https;host=A.test"], + ]; + deepStrictEqual( + create(1, fields, { url: "/p?q=1" }).URL.href, + "https://a.test/p?q=1", + ); + deepStrictEqual(create(1, fields).secure, true); + // The authority of a request-target in absolute-form comes first + const absolute = create(1, fields, { url: "http://B.test:443/p" }); + deepStrictEqual(absolute.URL.href, "https://b.test/p"); + deepStrictEqual(absolute.host, "b.test"); + // The default port of the protocol is not a part of the host + deepStrictEqual( + create(1, [["Forwarded", `for=1.1.1.1;proto=https;host="a.test:443"`]]) + .host, + "a.test", + ); + deepStrictEqual( + create(1, [["Forwarded", `for=1.1.1.1;host="a.test:443"`]]).host, + "a.test:443", + ); + deepStrictEqual(create(false, [["Host", "[::1]:80"]]).host, "[::1]"); + // `OPTIONS *` + const options = create(1, fields, { url: "*", method: "OPTIONS" }); + deepStrictEqual(options.URL.href, "https://a.test/*"); + deepStrictEqual(options.host, "a.test"); + // Without a host + deepStrictEqual(create(false).host, null); + throws(() => create(false).URL, badRequest); + // An invalid host + for (const value of [ + "", + "a b", + "user@a.test", + "a.test/x", + "a.test:x", + "[::1", + ]) { + throws(() => create(false, [["Host", value]]).host, badRequest, value); + throws(() => create(false, [["Host", value]]).URL, badRequest, value); + } + throws( + () => create(false, [host], { url: "ftp://a.test/" }).host, + badRequest, + ); + // Without a request-target (not parsed from a request) + const bare = create(false, [host]); + Reflect.deleteProperty(bare, "url"); + deepStrictEqual(bare.host, "origin.test"); + }); + + test("a real connection", { timeout: 5000 }, async () => { + class Trusting extends BindenRequest { + // eslint-disable-next-line class-methods-use-this -- a fixture + protected override get trust_proxy(): ITrustProxy { + return { trust: true, headers: "x-forwarded" }; + } + } + const server = createServer({ IncomingMessage: Trusting }); + const { promise, resolve, reject } = + Promise.withResolvers<[string | null, readonly string[], string]>(); + server.once("request", (request, response) => { + try { + resolve([request.ip, request.ips, request.protocol]); + } catch (error: unknown) { + reject(error); + } finally { + response.end(); + } + }); + await new Promise((done) => { + server.listen(0, done); + }); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1", () => { + // Two field lines of `X-Forwarded-Proto`: ambiguous + socket.end( + "GET / HTTP/1.1\r\nHost: a.test\r\nX-Forwarded-For: 1.1.1.1\r\nX-Forwarded-Proto: https\r\nX-Forwarded-Proto: https\r\nConnection: close\r\n\r\n", + ); + }); + socket.resume(); + try { + // The peer is IPv4 (also through an IPv6 socket) + deepStrictEqual(await promise, [ + "1.1.1.1", + ["1.1.1.1", "127.0.0.1"], + "http:", + ]); + } finally { + socket.destroy(); + await new Promise((done) => { + server.close(done); + }); + } + }); + + after(() => { + for (const socket of sockets.splice(0)) { + socket.destroy(); + } + }); +}); diff --git a/packages/binden/test/request.test.ts b/packages/binden/test/request.test.ts index 318715ab..e04cbd38 100644 --- a/packages/binden/test/request.test.ts +++ b/packages/binden/test/request.test.ts @@ -14,7 +14,6 @@ import { Forwarded, Cookie, IfModifiedSince, - type ITrustProxy, } from "../index.js"; /** The port of a listening server */ @@ -410,54 +409,6 @@ suite("BindenRequest", () => { deepStrictEqual(Object.keys(query).sort(), ["__proto__", "x"]); }); - test(".protocol (trusted proxies)", () => { - const create = ( - trust: ITrustProxy, - forwarded?: string, - socket = new Socket(), - ): BindenRequest => { - class Trusting extends BindenRequest { - // eslint-disable-next-line class-methods-use-this -- a fixture - protected override get trust_proxy(): ITrustProxy { - return trust; - } - } - const request = new Trusting(socket); - if (typeof forwarded === "string") { - request.headers = { forwarded }; - } - return request; - }; - // The client sent the first element, the nearest proxy appended the last one - const header = "for=1.2.3.4;proto=https, for=203.0.113.9;proto=http"; - - deepStrictEqual(create(false, header).protocol, "http:"); - deepStrictEqual(create(0, header).protocol, "http:"); - deepStrictEqual(create(1, header).protocol, "http:"); - deepStrictEqual(create(2, header).protocol, "https:"); - deepStrictEqual(create(5, header).protocol, "https:"); - deepStrictEqual(create(true, header).protocol, "https:"); - // Without the header or its `proto`: the protocol of the connection - deepStrictEqual(create(true).protocol, "http:"); - deepStrictEqual(create(true, "for=1.2.3.4").protocol, "http:"); - deepStrictEqual(create(true, "for=1.2.3.4;proto=ftp").protocol, "http:"); - // The trusted element is the one in its position: an invalid element of a - // proxy does not let the one of the client take its place - deepStrictEqual(create(1, "for=x;proto=https, garbage").protocol, "http:"); - deepStrictEqual(create(1, "proto=https, ext=1").protocol, "http:"); - deepStrictEqual(create(1, `proto=https, for=""`).protocol, "http:"); - deepStrictEqual(create(1, `proto=https, for="a,b"`).protocol, "http:"); - deepStrictEqual(create(1, "proto=http;proto=https").protocol, "http:"); - // Empty elements are not elements - deepStrictEqual(create(1, "proto=https, , ").protocol, "https:"); - // Over TLS, a trusted element without `proto` keeps `https:` - const tls = new TLSSocket(new Socket()); - deepStrictEqual(create(true, "for=1.2.3.4", tls).protocol, "https:"); - deepStrictEqual(create(true, header, tls).protocol, "https:"); - deepStrictEqual(create(1, header, tls).protocol, "http:"); - tls.destroy(); - }); - test(".forwarded", async () => { const forwarded = new Forwarded({ for: "8:8:8:8", proto: "https" }); const serverPromise = new Promise((resolve, reject) => { From 3f30b73b0a399fb86ef9d1e5de7d55dc347a7fb0 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:06:52 +0300 Subject: [PATCH 115/675] feat: add the options of the requests in binden A request read each of its settings from a protected getter (`trust_proxy`) that Binden had to override one by one, without a way to check them once. BindenRequest now has one protected `options` getter (IRequestOptions), and validateRequestOptions() checks the options as given (TypeError for a wrong type, RangeError for a number out of range) and fills in their defaults, so that Binden, or a subclass for http.createServer(), can set all of them at once. `query.max_keys` (1000 by default, `Infinity` for no limit) is the number of keys that request.query parses, the limit that querystring.parse() applied silently. The `trust_proxy` getter is deprecated: the default options still read it. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/request.ts | 83 ++++++++++++++++- packages/binden/test/proxy.test.ts | 20 +++- packages/binden/test/request.test.ts | 132 +++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 10 deletions(-) diff --git a/packages/binden/src/request.ts b/packages/binden/src/request.ts index 45be753a..b9eeca79 100644 --- a/packages/binden/src/request.ts +++ b/packages/binden/src/request.ts @@ -27,6 +27,61 @@ export type { IProxyHeaders, ITrustFn, ITrustProxy } from "./proxy.js"; export type IProtocol = "http:" | "https:"; +/** + * The settings of the requests of an application, checked and with their + * defaults (see `validateRequestOptions()`) + */ +export interface IRequestOptions { + /** The trusted proxies and their headers (none by default) */ + readonly trust_proxy: ITrustProxy; + /** The greatest number of keys that `request.query` parses (1000) */ + readonly query: { readonly max_keys: number }; +} + +/** The request options as given (to `Binden`), every one of them optional */ +export interface IRequestOptionsInput { + trust_proxy?: ITrustProxy | undefined; + query?: { max_keys?: number | undefined } | undefined; +} + +/** Throws unless the value is an object (or `undefined`, for the default) */ +function optionalObject(value: unknown, name: string): Record { + if (typeof value === "undefined") { + return {}; + } else if (typeof value !== "object" || value === null) { + throw new TypeError(`\`${name}\` is not an object`); + } + return value as Record; +} + +/** + * The request options with their defaults, checked: a `TypeError` for a value + * of another type, a `RangeError` for a number out of its range (e.g. + * `trust_proxy: -1`, or `query.max_keys` that is not a positive integer or + * `Infinity`). Other properties of the input are ignored. + */ +export function validateRequestOptions( + input?: IRequestOptionsInput, +): IRequestOptions { + const { trust_proxy = false, query } = optionalObject(input, "options"); + const { max_keys = 1000 } = optionalObject(query, "query"); + if (typeof max_keys !== "number") { + throw new TypeError("`query.max_keys` is not a number"); + } else if ( + max_keys !== Number.POSITIVE_INFINITY && + !(Number.isSafeInteger(max_keys) && max_keys > 0) + ) { + throw new RangeError("`query.max_keys` is not a positive integer"); + } + return Object.freeze({ + trust_proxy: checkTrustProxy(trust_proxy), + query: Object.freeze({ max_keys }), + }); +} + +/** The default options of the requests */ +const defaults = validateRequestOptions(); + export class BindenRequest extends IncomingMessage { #accept_encoding?: readonly AcceptEncoding[]; #authorization?: Authorization | null; @@ -193,18 +248,36 @@ export class BindenRequest extends IncomingMessage { } /** - * The proxies in front of the server that are trusted, and their headers - * (none by default). `Binden` sets it from its `trust_proxy` option; with - * `http.createServer()`, override it in a subclass. + * The settings of the requests (see `IRequestOptions`): the defaults, with + * the `trust_proxy` getter. `Binden` sets them from its options; with + * `http.createServer()`, override it in a subclass to return the result of + * `validateRequestOptions()`. + */ + protected get options(): Readonly { + return { ...defaults, trust_proxy: this.trust_proxy }; + } + + /** + * The trusted proxies (none by default), for the default `options` + * + * @deprecated Override `options` instead */ // eslint-disable-next-line class-methods-use-this -- overridden by subclasses protected get trust_proxy(): ITrustProxy { return false; } + /** + * The query of `URL`, parsed by `querystring.parse()`: a copy, with the + * first `query.max_keys` keys of the options (the others are ignored) + */ public get query(): ParsedUrlQuery { if (!this.#query) { - this.#query = parse(this.URL.search.substring(1)); + const { max_keys } = this.options.query; + this.#query = parse(this.URL.search.substring(1), "&", "=", { + // `0` is no limit + maxKeys: max_keys === Number.POSITIVE_INFINITY ? 0 : max_keys, + }); } // Defined, not assigned: a `__proto__` key is a key, not the prototype @@ -256,7 +329,7 @@ export class BindenRequest extends IncomingMessage { #resolve(): IProxyResult { this.#proxy ??= resolveProxy( this.socket.remoteAddress, - checkTrustProxy(this.trust_proxy), + checkTrustProxy(this.options.trust_proxy), this.headers, this.headersDistinct, ); diff --git a/packages/binden/test/proxy.test.ts b/packages/binden/test/proxy.test.ts index 4cf9db57..16e0e815 100644 --- a/packages/binden/test/proxy.test.ts +++ b/packages/binden/test/proxy.test.ts @@ -4,7 +4,13 @@ import { type AddressInfo, Socket, connect } from "node:net"; import { TLSSocket } from "node:tls"; import { after, suite, test } from "node:test"; -import { BindenError, BindenRequest, type ITrustProxy } from "../index.js"; +import { + BindenError, + BindenRequest, + type IRequestOptions, + type ITrustProxy, + validateRequestOptions, +} from "../index.js"; interface IFixture { /** The address of the peer (none: `undefined`) */ @@ -24,8 +30,9 @@ function create( ): BindenRequest { class Trusting extends BindenRequest { // eslint-disable-next-line class-methods-use-this -- a fixture - protected override get trust_proxy(): ITrustProxy { - return trust; + protected override get options(): IRequestOptions { + // Not checked: a request checks it too + return { ...validateRequestOptions(), trust_proxy: trust }; } } const socket = tls ? new TLSSocket(new Socket()) : new Socket(); @@ -613,10 +620,13 @@ suite("Trusted proxies", () => { }); test("a real connection", { timeout: 5000 }, async () => { + const options = validateRequestOptions({ + trust_proxy: { trust: true, headers: "x-forwarded" }, + }); class Trusting extends BindenRequest { // eslint-disable-next-line class-methods-use-this -- a fixture - protected override get trust_proxy(): ITrustProxy { - return { trust: true, headers: "x-forwarded" }; + protected override get options(): IRequestOptions { + return options; } } const server = createServer({ IncomingMessage: Trusting }); diff --git a/packages/binden/test/request.test.ts b/packages/binden/test/request.test.ts index e04cbd38..bc2ca001 100644 --- a/packages/binden/test/request.test.ts +++ b/packages/binden/test/request.test.ts @@ -14,6 +14,10 @@ import { Forwarded, Cookie, IfModifiedSince, + type IRequestOptions, + type IRequestOptionsInput, + type ITrustProxy, + validateRequestOptions, } from "../index.js"; /** The port of a listening server */ @@ -394,6 +398,53 @@ suite("BindenRequest", () => { tls.destroy(); }); + test(".query (`query.max_keys`)", () => { + const create = (input?: IRequestOptionsInput): BindenRequest => { + const options = validateRequestOptions(input); + class Limited extends BindenRequest { + // eslint-disable-next-line class-methods-use-this -- a fixture + protected override get options(): IRequestOptions { + return options; + } + } + const request = new Limited(new Socket()); + request.url = `/?${Array.from({ length: 1002 }, (_, i) => `k${i}=${i}`).join("&")}`; + request.headers = { host: "localhost" }; + return request; + }; + // 1000 keys by default, the others are ignored + deepStrictEqual(Object.keys(create().query).length, 1000); + deepStrictEqual(Object.keys(create().query).at(-1), "k999"); + const two = create({ query: { max_keys: 2 } }).query; + deepStrictEqual(two, { k0: "0", k1: "1" }); + deepStrictEqual( + Object.keys(create({ query: { max_keys: Infinity } }).query).length, + 1002, + ); + deepStrictEqual(Object.keys(create({ query: {} }).query).length, 1000); + // The default options of a request + const request = new BindenRequest(new Socket()); + request.url = "/?a=1&b=2"; + request.headers = { host: "localhost" }; + deepStrictEqual(request.query, { a: "1", b: "2" }); + }); + + test(".trust_proxy (deprecated)", () => { + class Trusting extends BindenRequest { + // eslint-disable-next-line class-methods-use-this -- a fixture + protected override get trust_proxy(): ITrustProxy { + return 1; + } + } + const socket = new Socket(); + Object.defineProperty(socket, "remoteAddress", { value: "10.0.0.1" }); + const request = new Trusting(socket); + request.headers = { forwarded: "for=1.1.1.1;proto=https" }; + // The default `options` take it + deepStrictEqual([request.ip, request.protocol], ["1.1.1.1", "https:"]); + socket.destroy(); + }); + test(".query (keeps its prototype)", () => { const request = new BindenRequest(new Socket()); request.url = "/?__proto__=a&__proto__=b&x=1"; @@ -519,3 +570,84 @@ suite("BindenRequest", () => { afterEach((_t, done) => server.close(done)); }); + +suite("validateRequestOptions()", () => { + test("defaults", () => { + const options = validateRequestOptions(); + deepStrictEqual(options, { + trust_proxy: { trust: false, headers: "forwarded" }, + query: { max_keys: 1000 }, + }); + ok(Object.isFrozen(options)); + ok(Object.isFrozen(options.query)); + ok(Object.isFrozen(options.trust_proxy)); + deepStrictEqual(validateRequestOptions({}), options); + // Other options are ignored + deepStrictEqual( + validateRequestOptions({ auto_head: true } as IRequestOptionsInput), + options, + ); + }); + + test("`trust_proxy`", () => { + const trust = (): boolean => true; + deepStrictEqual(validateRequestOptions({ trust_proxy: 2 }).trust_proxy, { + trust: 2, + headers: "forwarded", + }); + deepStrictEqual( + validateRequestOptions({ + trust_proxy: { trust, headers: "x-forwarded" }, + }).trust_proxy, + { trust, headers: "x-forwarded" }, + ); + throws( + () => validateRequestOptions({ trust_proxy: -1 }), + new RangeError("`trust_proxy` is not a non-negative integer"), + ); + throws( + () => + validateRequestOptions({ trust_proxy: "1" as unknown as ITrustProxy }), + new TypeError("`trust_proxy` is not a boolean, number or function"), + ); + }); + + test("`query`", () => { + deepStrictEqual(validateRequestOptions({ query: { max_keys: 1 } }).query, { + max_keys: 1, + }); + deepStrictEqual( + validateRequestOptions({ query: { max_keys: Infinity } }).query, + { max_keys: Infinity }, + ); + for (const max_keys of [0, -1, 1.5, Number.NaN, 2 ** 53, -Infinity]) { + throws( + () => validateRequestOptions({ query: { max_keys } }), + new RangeError("`query.max_keys` is not a positive integer"), + `${max_keys}`, + ); + } + throws( + () => + validateRequestOptions({ + query: { max_keys: "1" as unknown as number }, + }), + new TypeError("`query.max_keys` is not a number"), + ); + for (const query of [null, 1, "a"]) { + throws( + () => + validateRequestOptions({ + query: query as unknown as IRequestOptionsInput["query"], + }), + new TypeError("`query` is not an object"), + ); + } + for (const input of [null, 1, "a"]) { + throws( + () => validateRequestOptions(input as unknown as IRequestOptionsInput), + new TypeError("`options` is not an object"), + ); + } + }); +}); From f0fe80e8bdb6a3934276af062d9e294281c28833 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:09:02 +0300 Subject: [PATCH 116/675] feat!: refuse encoded slashes in the path of a request in binden A `%2F` or `%5C` in a path is a slash or a backslash once decoded: a router that decodes the segments sees one segment where a proxy or a file server sees several (or the opposite), a known way around access rules. request.URL now throws a BindenError (400) for a request-target whose path has `%2F` or `%5C` (in any case), unless the new `encoded_slashes` request option is true. The query is not checked, and `%252F` is fine: once decoded, it is the text `%2F`. BREAKING CHANGE: A request whose path has `%2F` or `%5C` gets a 400 Bad Request; set `encoded_slashes: true` in the request options to accept it. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/request.ts | 33 +++++++++++--- packages/binden/test/request.test.ts | 66 ++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/packages/binden/src/request.ts b/packages/binden/src/request.ts index b9eeca79..5963ca33 100644 --- a/packages/binden/src/request.ts +++ b/packages/binden/src/request.ts @@ -34,6 +34,11 @@ export type IProtocol = "http:" | "https:"; export interface IRequestOptions { /** The trusted proxies and their headers (none by default) */ readonly trust_proxy: ITrustProxy; + /** + * Whether the path of a request-target may have an encoded slash or + * backslash (`%2F`, `%5C`): a `400 Bad Request` otherwise (by default) + */ + readonly encoded_slashes: boolean; /** The greatest number of keys that `request.query` parses (1000) */ readonly query: { readonly max_keys: number }; } @@ -41,6 +46,7 @@ export interface IRequestOptions { /** The request options as given (to `Binden`), every one of them optional */ export interface IRequestOptionsInput { trust_proxy?: ITrustProxy | undefined; + encoded_slashes?: boolean | undefined; query?: { max_keys?: number | undefined } | undefined; } @@ -63,9 +69,15 @@ function optionalObject(value: unknown, name: string): Record { export function validateRequestOptions( input?: IRequestOptionsInput, ): IRequestOptions { - const { trust_proxy = false, query } = optionalObject(input, "options"); + const { + trust_proxy = false, + encoded_slashes = false, + query, + } = optionalObject(input, "options"); const { max_keys = 1000 } = optionalObject(query, "query"); - if (typeof max_keys !== "number") { + if (typeof encoded_slashes !== "boolean") { + throw new TypeError("`encoded_slashes` is not a boolean"); + } else if (typeof max_keys !== "number") { throw new TypeError("`query.max_keys` is not a number"); } else if ( max_keys !== Number.POSITIVE_INFINITY && @@ -75,6 +87,7 @@ export function validateRequestOptions( } return Object.freeze({ trust_proxy: checkTrustProxy(trust_proxy), + encoded_slashes, query: Object.freeze({ max_keys }), }); } @@ -313,7 +326,7 @@ export class BindenRequest extends IncomingMessage { const { path, query } = target === "*" && this.method === "OPTIONS" ? { path: "*", query: "" } - : BindenRequest.#target(rest); + : BindenRequest.#target(rest, this.options.encoded_slashes); const origin = this.#origin(authority); if (origin === null) { throw new BindenError(400); @@ -377,9 +390,14 @@ export class BindenRequest extends IncomingMessage { * The path and the query of a request-target (without its authority), which * must be in origin-form, without a fragment, a backslash, an invalid * percent-encoding, or dot-segments (which the URL parser would remove, so - * that the request would be routed to another path). + * that the request would be routed to another path); nor, unless + * `encoded_slashes`, with an encoded slash or backslash in its path (a + * segment that a router would see as several, once decoded). */ - static #target(rest: string): { path: string; query: string } { + static #target( + rest: string, + encoded_slashes: boolean, + ): { path: string; query: string } { if ( !rest.startsWith("/") || /[#\\]/u.test(rest) || @@ -391,7 +409,10 @@ export class BindenRequest extends IncomingMessage { const index = rest.indexOf("?"); const path = index === -1 ? rest : rest.slice(0, index); const query = index === -1 ? "" : rest.slice(index + 1); - if (path.split("/").some((part) => /^(?:\.|%2e){1,2}$/iu.test(part))) { + if ( + path.split("/").some((part) => /^(?:\.|%2e){1,2}$/iu.test(part)) || + (!encoded_slashes && /%(?:2f|5c)/iu.test(path)) + ) { throw new BindenError(400); } return { path, query }; diff --git a/packages/binden/test/request.test.ts b/packages/binden/test/request.test.ts index bc2ca001..34b2f962 100644 --- a/packages/binden/test/request.test.ts +++ b/packages/binden/test/request.test.ts @@ -27,6 +27,11 @@ function portOf(server: { address(): unknown }): number { let url = ""; +/** Throws a `BindenError` with the status 400 */ +function badRequest(error: unknown): boolean { + return error instanceof BindenError && error.status === 400; +} + suite("BindenRequest", () => { let server: Server; @@ -390,6 +395,50 @@ suite("BindenRequest", () => { } }); + test(".URL (`encoded_slashes`)", () => { + const create = ( + target: string, + encoded_slashes?: boolean, + ): BindenRequest => { + const options = validateRequestOptions({ encoded_slashes }); + class Encoded extends BindenRequest { + // eslint-disable-next-line class-methods-use-this -- a fixture + protected override get options(): IRequestOptions { + return options; + } + } + const request = new Encoded(new Socket()); + request.url = target; + request.headers = { host: "trusted.test" }; + return request; + }; + const encoded = [ + "/a%2Fb", + "/a%2fb", + "/a%5Cb", + "/a%5cb", + "/%2F", + "http://trusted.test/a%2Fb", + ]; + for (const target of encoded) { + // A segment that a router would see as several once decoded + throws(() => create(target).URL, badRequest, target); + throws(() => create(target, false).URL, badRequest, target); + deepStrictEqual( + create(target, true).URL.pathname, + target.replace("http://trusted.test", ""), + ); + } + // Decoded once, `%252F` is `%2F`; the query is not a path + deepStrictEqual(create("/a%252Fb").URL.pathname, "/a%252Fb"); + deepStrictEqual(create("/a?b=%2F%5C").URL.search, "?b=%2F%5C"); + // The default options of a request + const request = new BindenRequest(new Socket()); + request.url = "/a%2Fb"; + request.headers = { host: "trusted.test" }; + throws(() => request.URL, badRequest); + }); + test(".secure (the protocol of the connection)", () => { const tls = new TLSSocket(new Socket()); const request = new BindenRequest(tls); @@ -576,6 +625,7 @@ suite("validateRequestOptions()", () => { const options = validateRequestOptions(); deepStrictEqual(options, { trust_proxy: { trust: false, headers: "forwarded" }, + encoded_slashes: false, query: { max_keys: 1000 }, }); ok(Object.isFrozen(options)); @@ -612,6 +662,22 @@ suite("validateRequestOptions()", () => { ); }); + test("`encoded_slashes`", () => { + deepStrictEqual( + validateRequestOptions({ encoded_slashes: true }).encoded_slashes, + true, + ); + for (const encoded_slashes of [1, "true", null]) { + throws( + () => + validateRequestOptions({ + encoded_slashes: encoded_slashes as unknown as boolean, + }), + new TypeError("`encoded_slashes` is not a boolean"), + ); + } + }); + test("`query`", () => { deepStrictEqual(validateRequestOptions({ query: { max_keys: 1 } }).query, { max_keys: 1, From c67e96f341ee02a8ba06d64e246b2ace0e1088a1 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:10:12 +0300 Subject: [PATCH 117/675] fix!: refuse a request with several Host field lines in binden Node.js keeps the first one of several `Host` field lines, while a proxy in front of the server may have routed (or checked) the request by another one. RFC 9112 (section 3.2) requires a 400 for such a request: request.URL and request.host now throw a BindenError (400). BREAKING CHANGE: A request with several `Host` field lines gets a 400 Bad Request instead of being handled with the first one. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/request.ts | 8 ++++-- packages/binden/test/proxy.test.ts | 39 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/binden/src/request.ts b/packages/binden/src/request.ts index 5963ca33..31f16ad9 100644 --- a/packages/binden/src/request.ts +++ b/packages/binden/src/request.ts @@ -352,11 +352,15 @@ export class BindenRequest extends IncomingMessage { /** * The protocol and the effective authority of the request (see `host`), or * `null` without an authority. A `BindenError` (400) for an invalid one (not - * `uri-host [ ":" port ]`, e.g. with userinfo). + * `uri-host [ ":" port ]`, e.g. with userinfo), and for a request with + * several `Host` field lines (RFC 9112, section 3.2), of which Node.js keeps + * the first one while a proxy may have used another one. */ #origin(target: string | null): string | null { const authority = target ?? this.#resolve().host ?? this.headers.host; - if (typeof authority === "undefined") { + if ((this.headersDistinct.host ?? []).length > 1) { + throw new BindenError(400); + } else if (typeof authority === "undefined") { return null; } else if (!isAuthority(authority)) { throw new BindenError(400); diff --git a/packages/binden/test/proxy.test.ts b/packages/binden/test/proxy.test.ts index 16e0e815..9dc19929 100644 --- a/packages/binden/test/proxy.test.ts +++ b/packages/binden/test/proxy.test.ts @@ -613,12 +613,51 @@ suite("Trusted proxies", () => { () => create(false, [host], { url: "ftp://a.test/" }).host, badRequest, ); + // Several `Host` field lines (RFC 9112, section 3.2): Node.js keeps the + // first one, a proxy may have used another one + for (const url of ["/", "http://a.test/"]) { + const duplicated = create(false, [host, ["Host", "evil.test"]], { url }); + throws(() => duplicated.host, badRequest); + throws(() => duplicated.URL, badRequest); + } // Without a request-target (not parsed from a request) const bare = create(false, [host]); Reflect.deleteProperty(bare, "url"); deepStrictEqual(bare.host, "origin.test"); }); + test("several `Host` field lines", { timeout: 5000 }, async () => { + const server = createServer({ IncomingMessage: BindenRequest }); + const { promise, resolve } = Promise.withResolvers(); + server.once("request", (request, response) => { + try { + resolve(request.host); + } catch (error: unknown) { + resolve(error); + } finally { + response.end(); + } + }); + await new Promise((done) => { + server.listen(0, done); + }); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1", () => { + socket.end( + "GET / HTTP/1.1\r\nHost: a.test\r\nHost: b.test\r\nConnection: close\r\n\r\n", + ); + }); + socket.resume(); + try { + ok(badRequest(await promise)); + } finally { + socket.destroy(); + await new Promise((done) => { + server.close(done); + }); + } + }); + test("a real connection", { timeout: 5000 }, async () => { const options = validateRequestOptions({ trust_proxy: { trust: true, headers: "x-forwarded" }, From 328ee619d6567624debcc62b2a00ca675867dd61 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:16:45 +0300 Subject: [PATCH 118/675] feat: allow only the configured hosts of the requests in binden Every Host was accepted, and request.URL built links with it: a request with a forged Host (or a forwarded one) could poison caches, password reset links or redirects. The new `allowed_hosts` request option lists the hosts (in any case; `hostname` for any port, or `hostname:port`, the default port of the protocol counting as given) or takes a function of the effective host (`hostname[:port]`) that must return a boolean. It checks the effective host (the authority of an absolute-form request-target, then a host from trusted proxies, then `Host`): request.URL and request.host throw a BindenError (400) for another host or none, and the headers stay as they are. validateRequestOptions() refuses a list that is empty (no request could pass) and a host that a URL would read as another one (e.g. `1.2.3` as `1.2.0.3`), and writes the hosts in lowercase. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/request.ts | 103 +++++++- packages/binden/test/allowed-hosts.test.ts | 281 +++++++++++++++++++++ packages/binden/test/request.test.ts | 1 + 3 files changed, 381 insertions(+), 4 deletions(-) create mode 100644 packages/binden/test/allowed-hosts.test.ts diff --git a/packages/binden/src/request.ts b/packages/binden/src/request.ts index 31f16ad9..c69059d7 100644 --- a/packages/binden/src/request.ts +++ b/packages/binden/src/request.ts @@ -21,6 +21,7 @@ import { checkTrustProxy, isAuthority, resolveProxy, + strictBoolean, } from "./proxy.js"; export type { IProxyHeaders, ITrustFn, ITrustProxy } from "./proxy.js"; @@ -39,14 +40,28 @@ export interface IRequestOptions { * backslash (`%2F`, `%5C`): a `400 Bad Request` otherwise (by default) */ readonly encoded_slashes: boolean; + /** + * The hosts that the effective host of a request (see `request.host`) may + * be (any host by default): a `400 Bad Request` otherwise. A list of hosts + * (`hostname` for any port, or `hostname:port`), or a function of the host. + */ + readonly allowed_hosts: IAllowedHosts | null; /** The greatest number of keys that `request.query` parses (1000) */ readonly query: { readonly max_keys: number }; } +/** + * The allowed hosts: a list of hosts (`hostname` for any port, or + * `hostname:port`), or a function of the effective host (`hostname[:port]`, + * see `request.host`), called synchronously, that must return a boolean + */ +export type IAllowedHosts = readonly string[] | ((host: string) => boolean); + /** The request options as given (to `Binden`), every one of them optional */ export interface IRequestOptionsInput { trust_proxy?: ITrustProxy | undefined; encoded_slashes?: boolean | undefined; + allowed_hosts?: IAllowedHosts | undefined; query?: { max_keys?: number | undefined } | undefined; } @@ -60,6 +75,67 @@ function optionalObject(value: unknown, name: string): Record { return value as Record; } +/** `hostname [ ":" port ]`, the hostname being an IP literal or not */ +const allowedHost = /^(?\[[^\]]*\]|[^:]*)(?::(?\d{1,5}))?$/u; + +/** A name of labels of letters, digits, `_` and `-` (not at their ends) */ +const hostname = + /^[\dA-Za-z_](?:[\w-]{0,61}[\dA-Za-z_])?(?:\.[\dA-Za-z_](?:[\w-]{0,61}[\dA-Za-z_])?)*$/u; + +/** + * An allowed host, in lowercase (an IPv6 address compressed): a host name, an + * IPv4 address or an IPv6 address in brackets, with an optional port + */ +function checkAllowedHost(entry: unknown): string { + const { hostname: name = "", port = "" } = + typeof entry === "string" ? (allowedHost.exec(entry)?.groups ?? {}) : {}; + const value = Number(port); + // An IPv6 address in brackets is checked by the URL parser + const v6 = name.startsWith("["); + const named = !v6 && hostname.test(name) && name.length <= 253; + // The name of a URL (e.g. not `1.2.3`, which a URL reads as `1.2.0.3`) + const normal = + (v6 || named) && URL.canParse(`http://${name}`) + ? new URL(`http://${name}`).hostname + : ""; + if ( + normal === "" || + (named && normal !== name.toLowerCase()) || + (port !== "" && (value < 1 || value > 65535)) + ) { + throw new TypeError( + `\`allowed_hosts\` has an invalid host: ${String(entry)}`, + ); + } + return port === "" ? normal : `${normal}:${value}`; +} + +/** The checked `allowed_hosts` option (`null` for any host) */ +function checkAllowedHosts(value: unknown): IAllowedHosts | null { + if (typeof value === "undefined") { + return null; + } else if (typeof value === "function") { + return value as (host: string) => boolean; + } else if (!Array.isArray(value)) { + throw new TypeError("`allowed_hosts` is not an array or a function"); + } else if (value.length === 0) { + throw new RangeError("`allowed_hosts` is empty: no request is allowed"); + } + return Object.freeze(value.map((entry: unknown) => checkAllowedHost(entry))); +} + +/** Whether the URL is of the allowed host (a checked one; any port without one) */ +function isAllowed(entry: string, url: URL): boolean { + // The port follows the brackets of an IPv6 address + const index = entry.lastIndexOf(":"); + const [name, port] = + index > entry.lastIndexOf("]") + ? [entry.slice(0, index), entry.slice(index + 1)] + : [entry, ""]; + const effective = url.port || (url.protocol === "https:" ? "443" : "80"); + return name === url.hostname && (port === "" || port === effective); +} + /** * The request options with their defaults, checked: a `TypeError` for a value * of another type, a `RangeError` for a number out of its range (e.g. @@ -72,6 +148,7 @@ export function validateRequestOptions( const { trust_proxy = false, encoded_slashes = false, + allowed_hosts, query, } = optionalObject(input, "options"); const { max_keys = 1000 } = optionalObject(query, "query"); @@ -88,6 +165,7 @@ export function validateRequestOptions( return Object.freeze({ trust_proxy: checkTrustProxy(trust_proxy), encoded_slashes, + allowed_hosts: checkAllowedHosts(allowed_hosts), query: Object.freeze({ max_keys }), }); } @@ -352,20 +430,37 @@ export class BindenRequest extends IncomingMessage { /** * The protocol and the effective authority of the request (see `host`), or * `null` without an authority. A `BindenError` (400) for an invalid one (not - * `uri-host [ ":" port ]`, e.g. with userinfo), and for a request with - * several `Host` field lines (RFC 9112, section 3.2), of which Node.js keeps - * the first one while a proxy may have used another one. + * `uri-host [ ":" port ]`, e.g. with userinfo), for one that `allowed_hosts` + * does not allow (also a missing one), and for a request with several + * `Host` field lines (RFC 9112, section 3.2), of which Node.js keeps the + * first one while a proxy may have used another one. */ #origin(target: string | null): string | null { const authority = target ?? this.#resolve().host ?? this.headers.host; + const { allowed_hosts } = this.options; if ((this.headersDistinct.host ?? []).length > 1) { throw new BindenError(400); } else if (typeof authority === "undefined") { + if (allowed_hosts !== null) { + throw new BindenError(400); + } return null; } else if (!isAuthority(authority)) { throw new BindenError(400); } - return `${this.protocol}//${authority}`; + const origin = `${this.protocol}//${authority}`; + if (allowed_hosts === null) { + return origin; + } + const url = new URL(origin); + const allowed = + typeof allowed_hosts === "function" + ? strictBoolean(allowed_hosts(url.host), "allowed_hosts") + : allowed_hosts.some((entry) => isAllowed(entry, url)); + if (!allowed) { + throw new BindenError(400); + } + return origin; } /** diff --git a/packages/binden/test/allowed-hosts.test.ts b/packages/binden/test/allowed-hosts.test.ts new file mode 100644 index 00000000..02d488a0 --- /dev/null +++ b/packages/binden/test/allowed-hosts.test.ts @@ -0,0 +1,281 @@ +import { deepStrictEqual, ok, throws } from "node:assert"; +import { Socket } from "node:net"; +import { TLSSocket } from "node:tls"; +import { after, suite, test } from "node:test"; + +import { + BindenError, + BindenRequest, + type IAllowedHosts, + type IRequestOptions, + type IRequestOptionsInput, + validateRequestOptions, +} from "../index.js"; + +const sockets: Socket[] = []; + +/** A request with the options, from `10.0.0.1` */ +function create( + input: IRequestOptionsInput, + headers: Record = {}, + { url = "/", tls = false } = {}, +): BindenRequest { + const options = validateRequestOptions(input); + class Hosted extends BindenRequest { + // eslint-disable-next-line class-methods-use-this -- a fixture + protected override get options(): IRequestOptions { + return options; + } + } + const socket = tls ? new TLSSocket(new Socket()) : new Socket(); + sockets.push(socket); + Object.defineProperty(socket, "remoteAddress", { value: "10.0.0.1" }); + const request = new Hosted(socket); + request.url = url; + request.headers = headers; + return request; +} + +/** Throws a `BindenError` with the status 400 */ +function badRequest(error: unknown): boolean { + return error instanceof BindenError && error.status === 400; +} + +/** Whether the request is allowed, by `.host` and `.URL` */ +function allowed(request: BindenRequest): boolean { + try { + const { host } = request; + deepStrictEqual(request.URL.host, host); + return true; + } catch (error: unknown) { + ok(badRequest(error), String(error)); + throws(() => request.URL, badRequest); + return false; + } +} + +suite("allowed_hosts", () => { + test("any host by default", () => { + ok(allowed(create({}, { host: "evil.test" }))); + deepStrictEqual(create({}).host, null); + }); + + test("a list of hosts", () => { + const allowed_hosts = [ + "Example.com", + "api.example.com:8443", + "[0:0::1]:3000", + "10.0.0.1", + "secure.test:443", + "[::2]", + "plain.test:80", + ]; + const check = (host: string, tls = false): boolean => + allowed(create({ allowed_hosts }, { host }, { tls })); + + // In lowercase, on any port without one + for (const host of ["example.com", "EXAMPLE.com", "example.com:8080"]) { + ok(check(host), host); + } + ok(check("api.example.com:8443")); + ok(!check("api.example.com")); + ok(!check("api.example.com:443")); + ok(check("[::1]:3000")); + ok(check("[0::1]:3000")); + ok(!check("[::1]")); + ok(check("[::2]:9999")); + ok(check("10.0.0.1")); + ok(!check("10.0.0.2")); + ok(!check("evil.test")); + ok(!check("sub.example.com")); + ok(!check("example.com.evil.test")); + // The default port of the protocol + ok(check("secure.test", true)); + ok(!check("secure.test")); + ok(check("secure.test:443")); + ok(check("plain.test")); + ok(!check("plain.test", true)); + // Without a host + throws(() => create({ allowed_hosts }).host, badRequest); + ok(!allowed(create({ allowed_hosts }))); + // The original header is left as it is + const request = create({ allowed_hosts }, { host: "evil.test" }); + ok(!allowed(request)); + deepStrictEqual(request.headers.host, "evil.test"); + }); + + test("the effective host", () => { + const allowed_hosts = ["example.com"]; + // The authority of a request-target in absolute-form + ok( + !allowed( + create( + { allowed_hosts }, + { host: "example.com" }, + { url: "http://evil.test/" }, + ), + ), + ); + ok( + allowed( + create( + { allowed_hosts }, + { host: "evil.test" }, + { url: "http://example.com/" }, + ), + ), + ); + // The host from a trusted proxy + const proxied = (host: string): BindenRequest => + create( + { allowed_hosts, trust_proxy: 1 }, + { host: "example.com", forwarded: `for=1.1.1.1;host=${host}` }, + ); + ok(!allowed(proxied("evil.test"))); + ok(allowed(proxied("example.com"))); + // ... but not from another one + ok( + allowed( + create( + { allowed_hosts }, + { host: "example.com", forwarded: "for=1.1.1.1;host=evil.test" }, + ), + ), + ); + }); + + test("a function", () => { + const hosts: string[] = []; + const allowed_hosts = (host: string): boolean => { + hosts.push(host); + return /\.example\.com(?::\d+)?$/u.test(host); + }; + ok(allowed(create({ allowed_hosts }, { host: "A.example.com:8080" }))); + ok(allowed(create({ allowed_hosts }, { host: "b.example.com:80" }))); + ok(!allowed(create({ allowed_hosts }, { host: "evil.test" }))); + // `hostname[:port]`, in lowercase, without the default port + deepStrictEqual(hosts.slice(0, 4), [ + "a.example.com:8080", + "a.example.com:8080", + "b.example.com", + "b.example.com", + ]); + ok(hosts.includes("evil.test")); + // Not called without a host + hosts.length = 0; + ok(!allowed(create({ allowed_hosts }))); + deepStrictEqual(hosts, []); + }); + + test("a function that does not return a boolean", async () => { + const unhandled: unknown[] = []; + const listener = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on("unhandledRejection", listener); + try { + const rejected = Promise.reject(new Error("Rejected")); + for (const result of [rejected, 1, "true", {}, null]) { + const allowed_hosts = (() => result) as unknown as IAllowedHosts; + const request = create({ allowed_hosts }, { host: "example.com" }); + // A configuration error: a TypeError (500), never an implicit `true` + const error = new TypeError( + "`allowed_hosts` has not returned a boolean", + ); + throws(() => request.host, error); + throws(() => request.URL, error); + } + await new Promise((resolve) => { + setImmediate(resolve); + }); + deepStrictEqual(unhandled, []); + } finally { + process.off("unhandledRejection", listener); + } + }); + + test("validateRequestOptions()", () => { + const check = (allowed_hosts: unknown): IAllowedHosts | null => + validateRequestOptions({ + allowed_hosts: allowed_hosts as IAllowedHosts, + }).allowed_hosts; + + deepStrictEqual(validateRequestOptions({}).allowed_hosts, null); + const fn = (): boolean => true; + deepStrictEqual(check(fn), fn); + const list = check([ + "Example.COM", + "example.com:080", + "a_b-c.d:65535", + "xn--bcher-kva.example:1", + "[0:0:0::1]", + "[2001:DB8::1]:8080", + "127.0.0.1", + "localhost", + ]); + deepStrictEqual(list, [ + "example.com", + "example.com:80", + "a_b-c.d:65535", + "xn--bcher-kva.example:1", + "[::1]", + "[2001:db8::1]:8080", + "127.0.0.1", + "localhost", + ]); + ok(Object.isFrozen(list)); + + for (const entry of [ + "", + "*.example.com", + "example.com.", + ".example.com", + "-a.example.com", + "a-.example.com", + "a..b", + "a b", + "example.com:0", + "example.com:65536", + "example.com:", + "example.com:x", + "user@example.com", + "example.com/x", + "1.2.3", + "01.2.3.4", + "0x7f.1", + "[1.2.3.4]", + "[::1", + "::1", + "[::1]x", + `${"a".repeat(64)}.test`, + `${"a.".repeat(127)}ab`, + "bücher.example", + 1, + null, + ]) { + throws( + () => check([entry]), + new TypeError( + `\`allowed_hosts\` has an invalid host: ${String(entry)}`, + ), + String(entry), + ); + } + throws( + () => check([]), + new RangeError("`allowed_hosts` is empty: no request is allowed"), + ); + for (const value of [null, "example.com", 1, {}]) { + throws( + () => check(value), + new TypeError("`allowed_hosts` is not an array or a function"), + ); + } + }); + + after(() => { + for (const socket of sockets.splice(0)) { + socket.destroy(); + } + }); +}); diff --git a/packages/binden/test/request.test.ts b/packages/binden/test/request.test.ts index 34b2f962..ab0b155a 100644 --- a/packages/binden/test/request.test.ts +++ b/packages/binden/test/request.test.ts @@ -626,6 +626,7 @@ suite("validateRequestOptions()", () => { deepStrictEqual(options, { trust_proxy: { trust: false, headers: "forwarded" }, encoded_slashes: false, + allowed_hosts: null, query: { max_keys: 1000 }, }); ok(Object.isFrozen(options)); From 720ce3e538d3a2fd12ab1875e5af108a0949fb67 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:23:24 +0300 Subject: [PATCH 119/675] feat: trust the incoming request ids only when configured in binden request.id was always a new UUID, so that the logs of a request could not be joined with those of the proxies and services before it. The new `request_id` request option sets the field of the id (`x-request-id` by default), whether an incoming id is used (`trust_incoming`, which is `false` by default since a client could set any id) and whether the response sends it back (`echo`, `true` by default, for Binden to apply). An incoming id is used only with `trust_incoming: true`, one field line and 1 to 128 letters, digits, `.`, `_`, `:` and `-`, which keeps it safe to log; anything else gets a new UUID. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/request.ts | 73 ++++++++- packages/binden/test/request-id.test.ts | 206 ++++++++++++++++++++++++ packages/binden/test/request.test.ts | 1 + 3 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 packages/binden/test/request-id.test.ts diff --git a/packages/binden/src/request.ts b/packages/binden/src/request.ts index c69059d7..b5a1f792 100644 --- a/packages/binden/src/request.ts +++ b/packages/binden/src/request.ts @@ -14,6 +14,7 @@ import ContentType from "./headers/content-type.js"; import Cookie from "./headers/cookie.js"; import Forwarded from "./headers/forwarded.js"; import IfModifiedSince from "./headers/if-modified-since.js"; +import { token } from "./headers/list.js"; import Range from "./headers/range.js"; import { type IProxyResult, @@ -46,6 +47,12 @@ export interface IRequestOptions { * (`hostname` for any port, or `hostname:port`), or a function of the host. */ readonly allowed_hosts: IAllowedHosts | null; + /** + * The request ids: their field name (in lowercase), whether an incoming id + * is trusted, and whether the id is sent back in the response (by + * `Binden`). `null` (by default): new ids, not sent back. + */ + readonly request_id: IRequestIdSettings | null; /** The greatest number of keys that `request.query` parses (1000) */ readonly query: { readonly max_keys: number }; } @@ -57,11 +64,33 @@ export interface IRequestOptions { */ export type IAllowedHosts = readonly string[] | ((host: string) => boolean); +/** The settings of the request ids, checked and with their defaults */ +export interface IRequestIdSettings { + readonly header: string; + readonly trust_incoming: boolean; + readonly echo: boolean; +} + +/** The request ids, as given */ +export interface IRequestIdOptions { + /** The field of the id (`x-request-id` by default) */ + header?: string | undefined; + /** + * Whether the id of the request (one field line of 1 to 128 letters, digits, + * `.`, `_`, `:` and `-`) is used instead of a new one (`false` by default): + * only when the proxies in front of the server set or check it + */ + trust_incoming?: boolean | undefined; + /** Whether the response sends the id back (`true` by default) */ + echo?: boolean | undefined; +} + /** The request options as given (to `Binden`), every one of them optional */ export interface IRequestOptionsInput { trust_proxy?: ITrustProxy | undefined; encoded_slashes?: boolean | undefined; allowed_hosts?: IAllowedHosts | undefined; + request_id?: IRequestIdOptions | undefined; query?: { max_keys?: number | undefined } | undefined; } @@ -136,6 +165,29 @@ function isAllowed(entry: string, url: URL): boolean { return name === url.hostname && (port === "" || port === effective); } +/** The checked `request_id` option, with its defaults (`null` without it) */ +function checkRequestId(value: unknown): IRequestIdSettings | null { + if (typeof value === "undefined") { + return null; + } + const { + header = "x-request-id", + trust_incoming = false, + echo = true, + } = optionalObject(value, "request_id"); + if (typeof header !== "string" || !token.test(header)) { + throw new TypeError("`request_id.header` is not a field name"); + } else if (typeof trust_incoming !== "boolean") { + throw new TypeError("`request_id.trust_incoming` is not a boolean"); + } else if (typeof echo !== "boolean") { + throw new TypeError("`request_id.echo` is not a boolean"); + } + return Object.freeze({ header: header.toLowerCase(), trust_incoming, echo }); +} + +/** An id that a request may carry */ +const incomingId = /^[\w.:-]{1,128}$/u; + /** * The request options with their defaults, checked: a `TypeError` for a value * of another type, a `RangeError` for a number out of its range (e.g. @@ -149,6 +201,7 @@ export function validateRequestOptions( trust_proxy = false, encoded_slashes = false, allowed_hosts, + request_id, query, } = optionalObject(input, "options"); const { max_keys = 1000 } = optionalObject(query, "query"); @@ -166,6 +219,7 @@ export function validateRequestOptions( trust_proxy: checkTrustProxy(trust_proxy), encoded_slashes, allowed_hosts: checkAllowedHosts(allowed_hosts), + request_id: checkRequestId(request_id), query: Object.freeze({ max_keys }), }); } @@ -280,10 +334,13 @@ export class BindenRequest extends IncomingMessage { return this.headers[name.toLowerCase()]; } + /** + * The id of the request: a new UUID or, with `request_id.trust_incoming`, + * the one of the request (one field line of 1 to 128 letters, digits, `.`, + * `_`, `:` and `-`) + */ public get id(): string { - if (typeof this.#id === "undefined") { - this.#id = randomUUID(); - } + this.#id ??= this.#incomingId() ?? randomUUID(); return this.#id; } @@ -416,6 +473,16 @@ export class BindenRequest extends IncomingMessage { return url; } + /** The trusted id of the request, or `null` */ + #incomingId(): string | null { + const { request_id } = this.options; + if (request_id?.trust_incoming !== true) { + return null; + } + const values = this.headersDistinct[request_id.header.toLowerCase()] ?? []; + return values.length === 1 && incomingId.test(values[0]) ? values[0] : null; + } + /** What the trusted proxies tell about the client, found once */ #resolve(): IProxyResult { this.#proxy ??= resolveProxy( diff --git a/packages/binden/test/request-id.test.ts b/packages/binden/test/request-id.test.ts new file mode 100644 index 00000000..78392ba4 --- /dev/null +++ b/packages/binden/test/request-id.test.ts @@ -0,0 +1,206 @@ +import { deepStrictEqual, ok, throws } from "node:assert"; +import { createServer } from "node:http"; +import { type AddressInfo, Socket, connect } from "node:net"; +import { after, suite, test } from "node:test"; + +import { + BindenRequest, + type IRequestIdOptions, + type IRequestOptions, + validateRequestOptions, +} from "../index.js"; + +const uuid = + /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/u; + +const sockets: Socket[] = []; + +/** A class of requests with the options */ +function withOptions(options: IRequestOptions): typeof BindenRequest { + return class extends BindenRequest { + // eslint-disable-next-line class-methods-use-this -- a fixture + protected override get options(): IRequestOptions { + return options; + } + }; +} + +/** A request with the field lines, and the request ids */ +function create( + request_id: IRequestIdOptions | null, + fields: [string, string][] = [], +): BindenRequest { + const options = + request_id === null + ? validateRequestOptions() + : validateRequestOptions({ request_id }); + const Request = withOptions(options); + const socket = new Socket(); + sockets.push(socket); + const request = new Request(socket); + const distinct: NodeJS.Dict = {}; + for (const [name, value] of fields) { + const key = name.toLowerCase(); + distinct[key] = [...(distinct[key] ?? []), value]; + } + request.headersDistinct = distinct; + return request; +} + +suite("request ids", () => { + test("new ids by default", () => { + const fields: [string, string][] = [["X-Request-Id", "abc"]]; + for (const request_id of [null, {}, { trust_incoming: false }]) { + const request = create(request_id, fields); + const { id } = request; + ok(uuid.test(id), id); + // Found once + deepStrictEqual(request.id, id); + } + const [first, second] = [create(null), create(null)]; + ok(first.id !== second.id); + }); + + test("trusted incoming ids", () => { + const id = (value: string, header = "X-Request-Id"): string => + create({ trust_incoming: true }, [[header, value]]).id; + + for (const value of ["abc", "A-b_c.d:e", "1", "x".repeat(128)]) { + deepStrictEqual(id(value), value); + } + // Not an id: a new one + for (const value of [ + "", + "a b", + "a,b", + "a/b", + "é", + "x".repeat(129), + "a\tb", + ]) { + ok(uuid.test(id(value)), value); + } + // Another field + ok(uuid.test(id("abc", "X-Correlation-Id"))); + deepStrictEqual( + create({ trust_incoming: true, header: "X-Correlation-Id" }, [ + ["X-Correlation-Id", "abc"], + ]).id, + "abc", + ); + // Several field lines: a new one + ok( + uuid.test( + create({ trust_incoming: true }, [ + ["X-Request-Id", "abc"], + ["X-Request-Id", "abc"], + ]).id, + ), + ); + // Only `true` trusts an incoming id (e.g. not a value of unchecked options) + const Request = withOptions({ + ...validateRequestOptions(), + request_id: { + header: "X-Request-Id", + trust_incoming: 1 as unknown as boolean, + echo: true, + }, + }); + const request = new Request(new Socket()); + request.headersDistinct = { "x-request-id": ["abc"] }; + ok(uuid.test(request.id)); + // The name of the field in any case + const Lower = withOptions({ + ...validateRequestOptions(), + request_id: { header: "X-Request-Id", trust_incoming: true, echo: true }, + }); + const lower = new Lower(new Socket()); + lower.headersDistinct = { "x-request-id": ["abc"] }; + deepStrictEqual(lower.id, "abc"); + }); + + test("a real connection", { timeout: 5000 }, async () => { + const Request = withOptions( + validateRequestOptions({ request_id: { trust_incoming: true } }), + ); + const server = createServer({ IncomingMessage: Request }); + const ids: string[] = []; + server.on("request", (request, response) => { + ids.push(request.id); + response.end(); + }); + await new Promise((resolve) => { + server.listen(0, resolve); + }); + const { port } = server.address() as AddressInfo; + const socket = connect(port, "127.0.0.1", () => { + socket.end( + "GET / HTTP/1.1\r\nHost: a.test\r\nX-Request-Id: abc\r\n\r\nGET / HTTP/1.1\r\nHost: a.test\r\nX-Request-Id: abc\r\nX-Request-Id: def\r\nConnection: close\r\n\r\n", + ); + }); + socket.resume(); + try { + await new Promise((resolve) => { + socket.once("close", resolve); + }); + deepStrictEqual(ids.length, 2); + deepStrictEqual(ids[0], "abc"); + ok(uuid.test(ids[1])); + } finally { + socket.destroy(); + await new Promise((resolve) => { + server.close(resolve); + }); + } + }); + + test("validateRequestOptions()", () => { + const check = (request_id: unknown): IRequestOptions["request_id"] => + validateRequestOptions({ + request_id: request_id as IRequestIdOptions, + }).request_id; + + deepStrictEqual(validateRequestOptions().request_id, null); + const defaults = check({}); + deepStrictEqual(defaults, { + header: "x-request-id", + trust_incoming: false, + echo: true, + }); + ok(Object.isFrozen(defaults)); + deepStrictEqual( + check({ header: "X-Correlation-ID", trust_incoming: true, echo: false }), + { header: "x-correlation-id", trust_incoming: true, echo: false }, + ); + for (const header of ["", "x request", "x:y", 1, null]) { + throws( + () => check({ header }), + new TypeError("`request_id.header` is not a field name"), + ); + } + for (const trust_incoming of [1, "true", null]) { + throws( + () => check({ trust_incoming }), + new TypeError("`request_id.trust_incoming` is not a boolean"), + ); + } + for (const echo of [0, "false", null]) { + throws( + () => check({ echo }), + new TypeError("`request_id.echo` is not a boolean"), + ); + } + for (const request_id of [null, true, "x-request-id"]) { + throws( + () => check(request_id), + new TypeError("`request_id` is not an object"), + ); + } + }); + + after(() => { + for (const socket of sockets.splice(0)) { + socket.destroy(); + } + }); +}); diff --git a/packages/binden/test/request.test.ts b/packages/binden/test/request.test.ts index ab0b155a..59979ec3 100644 --- a/packages/binden/test/request.test.ts +++ b/packages/binden/test/request.test.ts @@ -627,6 +627,7 @@ suite("validateRequestOptions()", () => { trust_proxy: { trust: false, headers: "forwarded" }, encoded_slashes: false, allowed_hosts: null, + request_id: null, query: { max_keys: 1000 }, }); ok(Object.isFrozen(options)); From d985cbe7e1893a5a3e562faec3de59a5be267932 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:28:14 +0300 Subject: [PATCH 120/675] docs: document the options and the client of the requests in binden The README described `trust_proxy` as a number of `Forwarded` elements for request.protocol only. It now documents the request options (validateRequestOptions(), and the `options` getter for http.createServer()), the walk through the trusted proxies with examples behind nginx, an AWS Application Load Balancer, `Forwarded` proxies and proxies trusted by address, the allowed hosts, the request ids, the encoded slashes, request.ip, ips, host and the accepts*() methods, and the new Accept, Accept-Charset and Accept-Language headers. The exported symbols of the request and of the headers get a JSDoc summary for the API documentation. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 219 +++++++++++++++++- packages/binden/src/headers/accept-charset.ts | 1 + .../binden/src/headers/accept-encoding.ts | 3 + .../binden/src/headers/accept-language.ts | 1 + packages/binden/src/headers/accept.ts | 1 + packages/binden/src/headers/authorization.ts | 3 + .../binden/src/headers/content-encoding.ts | 3 + packages/binden/src/headers/content-range.ts | 2 + packages/binden/src/headers/content-type.ts | 2 + packages/binden/src/headers/cookie.ts | 2 + packages/binden/src/headers/forwarded.ts | 2 + .../binden/src/headers/if-modified-since.ts | 2 + packages/binden/src/headers/range.ts | 3 + packages/binden/src/request.ts | 24 +- 14 files changed, 256 insertions(+), 12 deletions(-) diff --git a/packages/binden/README.md b/packages/binden/README.md index 288f558d..f4fc2af8 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -17,7 +17,7 @@ npm install binden - `auto_head` (default `true`) - a router without `HEAD` middlewares handles `HEAD` with its `GET` middlewares (see [Router](#router)) - `error_handler` - replaces the default reply to an error thrown by a middleware (the thrown value may be anything, also `null`). It may be `async`: it is awaited, and it must have ended its reply when it settles. Nothing started then gets the default reply, while a reply that is not ended (e.g. a `write()` without `end()`) is cut by closing the connection. When it fails, the connection is closed. There is no deadline: a handler that never settles keeps its request. - `logger` - see [Logging](#logging) -- `trust_proxy` (default `false`) - the proxies in front of the server whose [`Forwarded`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded) elements `request.protocol` trusts: none (`false`), all of them (`true`), or the given number of the nearest ones (each proxy appends its element, so the leftmost ones come from the client) +- `trust_proxy`, `allowed_hosts`, `request_id`, `encoded_slashes`, `query` - the options of the requests, see [Request options](#request-options) - `.use()` - Add a `Middleware/Router` to the stack @@ -420,13 +420,25 @@ const server = createServer({ IncomingMessage: BindenRequest }); const rawHeader = request.header("X-Header"); ``` -- `.id` - get the request id +- `.id` - the id of the request: a new UUID, or the id that the request carries when it is trusted (see [Request ids](#request-ids)) ```typescript const { id } = request; ``` -- `.protocol` - the protocol of the connection or, behind trusted proxies (the `trust_proxy` option of `Binden`), the `proto` of the [Forwarded](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded) element of the outermost of them: the element in its position (counted from the nearest proxy), and the protocol of the connection when that element is invalid or has no `proto`. With `http.createServer()`, override the protected `trust_proxy` getter in a subclass of `BindenRequest`: +- `.ip`, `.ips` - the address of the client: the peer (without the `::ffff:` prefix of an IPv4-mapped address) or, behind [trusted proxies](#proxies), the address that they have received; and the addresses of the client and of the trusted proxies, from left to right. `.ip` is `null` when the client is unknown (e.g. `for=unknown`) + +```typescript +const { ip, ips } = request; +``` + +- `.host` - the effective host of the request (`hostname[:port]`, in lowercase, without the default port of the protocol): the authority of a request-target in absolute-form, otherwise the host from [trusted proxies](#proxies), otherwise `Host`; `null` without any. A host that is invalid, or not one of the [allowed hosts](#allowed-hosts), throws a `BindenError` with the status 400 + +```typescript +const { host } = request; +``` + +- `.protocol` - the protocol of the connection or, behind [trusted proxies](#proxies), the one that they have received ```typescript const { protocol } = request; @@ -444,13 +456,13 @@ if (!secure) { } ``` -- `.URL` - the URL of the request: the protocol of the connection (`.protocol`), the authority of an absolute-form request-target (otherwise of `Host`), and the path and query of the request-target, set as such (`//a/b` is a path). A request-target with a fragment, a backslash, an invalid percent-encoding or a dot-segment (`/a/../b`, also percent-encoded, which would route the request to another path), and a `Host` that is not a host and a port, throw a `BindenError` with the status 400 (which `Binden` replies) +- `.URL` - the URL of the request: the protocol (`.protocol`), the effective host (`.host`), and the path and query of the request-target, set as such (`//a/b` is a path). It throws a `BindenError` with the status 400 (which `Binden` replies) for a request-target with a fragment, a backslash, an invalid percent-encoding or a dot-segment (`/a/../b`, also percent-encoded, which would route the request to another path), for a path with an encoded slash or backslash (`%2F`, `%5C`, see [Encoded slashes](#encoded-slashes)), for a host that is missing, invalid or not allowed, and for a request with several `Host` field lines ```typescript const { pathname } = request.URL; ``` -- `.query` - A copy of `URL.search` parsed by the `querystring.parse()` method +- `.query` - a copy of `URL.search` parsed by the `querystring.parse()` method, with at most `query.max_keys` keys (1000 by default, the others are ignored) ```typescript import { parse } from "node:querystring"; @@ -460,6 +472,148 @@ const { query } = request; const parsed = { ...parse(request.URL.search.substring(1)) }; ``` +- `.accepts()`, `.acceptsCharsets()`, `.acceptsEncodings()`, `.acceptsLanguages()` - the preferred one of the offered media types, charsets, content codings or language tags (as given), by the `Accept`, `Accept-Charset`, `Accept-Encoding` and `Accept-Language` headers ([RFC 9110, section 12.5](https://www.rfc-editor.org/rfc/rfc9110#section-12.5)): the first one without the header, `false` when none is acceptable. An offer takes the weight of the most specific element that matches it (`text/html;q=0` excludes HTML, even with `*/*`), and on a tie the more specific element wins, then the earlier element, then the earlier offer + +```typescript +import { BindenError } from "binden"; + +const type = request.accepts("application/json", "text/html"); +if (type === false) { + throw new BindenError(406); +} +const language = request.acceptsLanguages("en", "fr") || "en"; +``` + +#### Request options + +The requests of a `Binden` application take these options of `new Binden()`, checked by `validateRequestOptions()` (a `TypeError` for a wrong type, a `RangeError` for a value out of its range, e.g. `trust_proxy: -1`): + +- `trust_proxy` (default `false`) - the trusted proxies, see [Proxies](#proxies) +- `allowed_hosts` - the hosts that the requests may have (any by default), see [Allowed hosts](#allowed-hosts) +- `request_id` - the request ids, see [Request ids](#request-ids) +- `encoded_slashes` (default `false`) - see [Encoded slashes](#encoded-slashes) +- `query.max_keys` (default `1000`) - the greatest number of keys that `request.query` parses (`Infinity` for no limit) + +With `http.createServer()`, return them from the protected `options` getter of a subclass of `BindenRequest`: + +```typescript +import { createServer } from "node:http"; +import { + BindenRequest, + type IRequestOptions, + validateRequestOptions, +} from "binden"; + +const options = validateRequestOptions({ + trust_proxy: 1, + allowed_hosts: ["example.com"], +}); + +class Request extends BindenRequest { + protected override get options(): IRequestOptions { + return options; + } +} + +const server = createServer({ IncomingMessage: Request }); +``` + +#### Proxies + +A client can send any `Forwarded` or `X-Forwarded-*` header: by default, the requests trust no proxy, `.ip` and `.protocol` are those of the connection, and `.host` is `Host`. With `trust_proxy`, they are found by walking the hops from the server: the peer (hop 0), then the addresses of the header from right to left (every proxy appends the address of its own client). The walk goes on while the hops are trusted, and the client is the first address that is not (the leftmost one when all of them are). `trust_proxy` is + +- `false` (none), `true` (all of them), or the number of the nearest trusted hops: `1` for one proxy in front of the server; +- a function `(address, hop) => boolean` that tells whether the address of a hop is a trusted proxy. It is called synchronously and must return a boolean: anything else (e.g. a promise) is an error of the configuration, replied with a 500, never an implicit trust; +- `{ trust, headers }`, where `trust` is one of the above, and `headers` the headers that the proxies send: `"forwarded"` (the default, [RFC 7239](https://www.rfc-editor.org/rfc/rfc7239)) or `"x-forwarded"` (`X-Forwarded-For`, `X-Forwarded-Proto` and `X-Forwarded-Host`). The families are not mixed. + +The walk stops with an unknown client (`.ip` is `null`) on `unknown` or an obfuscated identifier (`_hidden`), and on an invalid element; an invalid element also makes `.protocol` that of the connection and `.host` `Host`, so that the element of a client never takes the place of the one of a proxy. With `Forwarded`, the protocol and the host are the `proto` and `host` of the element of the farthest trusted proxy. With `X-Forwarded-*`, they are the `X-Forwarded-Proto` and `X-Forwarded-Host` that the trusted peer has set: one field line of one value each, or those of the connection and `Host` when one of them is duplicated, a list or invalid. + +Behind nginx, which sets the headers like this: + +``` +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-Host $host; +``` + +trust the peer (one hop) and its headers. The same goes behind an AWS Application Load Balancer, which appends to `X-Forwarded-For` and sets `X-Forwarded-Proto` (not `X-Forwarded-Host`: the host is `Host`): + +```typescript +import { Binden } from "binden"; + +const app = new Binden({ trust_proxy: { trust: 1, headers: "x-forwarded" } }); +``` + +Behind proxies that send `Forwarded` (e.g. `Forwarded: for=192.0.2.60;proto=https;host=example.com`), count them: + +```typescript +import { Binden } from "binden"; + +// A CDN, then a load balancer +const app = new Binden({ trust_proxy: 2 }); +``` + +Or trust the addresses of the proxies, wherever they are in the chain: + +```typescript +import { BlockList, isIPv6 } from "node:net"; +import { Binden } from "binden"; + +const proxies = new BlockList(); +proxies.addSubnet("10.0.0.0", 8); +proxies.addSubnet("fd00::", 8, "ipv6"); + +const app = new Binden({ + trust_proxy: { + trust: (address) => + proxies.check(address, isIPv6(address) ? "ipv6" : "ipv4"), + headers: "x-forwarded", + }, +}); +``` + +Trust only the proxies that overwrite or append to these headers: a trusted proxy that passes on the headers of its client lets the client choose its address, protocol and host. + +#### Allowed hosts + +`request.URL` (and the links that an application builds with it) has the host of the request, which a client chooses. `allowed_hosts` lists the hosts that the effective host (`.host`) may be: `hostname` (on any port) or `hostname:port` (the default port of the protocol counts as given), in any case, or a function of the effective host (`hostname[:port]`) that returns a boolean (synchronously, like `trust_proxy`). Another host, or none, throws a `BindenError` with the status 400 when `.URL` or `.host` is read; the headers are left as they are. An empty list (that would refuse every request) throws a `RangeError`, and a host that is not a host name, an IPv4 address or an IPv6 address in brackets (with a port from 1 to 65535) a `TypeError`. + +```typescript +import { Binden } from "binden"; + +const app = new Binden({ + allowed_hosts: ["example.com", "www.example.com", "localhost:3000"], +}); +const api = new Binden({ + allowed_hosts: (host) => /^[\w-]+\.example\.com$/u.test(host), +}); +``` + +#### Request ids + +`request.id` is a new UUID for every request. With `request_id`, a request may carry its id, so that the logs of the proxies and services in front of the server can be joined with those of the application: + +- `header` (default `"x-request-id"`) - the field of the id +- `trust_incoming` (default `false`) - whether the id of the request is used: only when the proxies in front of the server set (or check) it, since a client can send any id. The id is used when it is one field line of 1 to 128 letters, digits, `.`, `_`, `:` and `-`; otherwise the request gets a new UUID +- `echo` (default `true`) - whether the response sends the id back (in the field `header`) + +```typescript +import { Binden } from "binden"; + +// nginx sets it: `proxy_set_header X-Request-Id $request_id;` +const app = new Binden({ request_id: { trust_incoming: true } }); +``` + +#### Encoded slashes + +A `%2F` (or `%5C`) in a path is a slash (or a backslash) once decoded: a router would see one segment where a proxy or a file server sees several, or the opposite. A request whose path has one (in any case) gets a 400, unless `encoded_slashes` is `true`. The query is not checked, and `%252F` is fine: once decoded, it is the text `%2F`. + +```typescript +import { Binden } from "binden"; + +const app = new Binden({ encoded_slashes: true }); +``` + ### BindenResponse Simple usage with `http` @@ -581,14 +735,61 @@ await response.sendFile(path); ### Headers -- [Accept-Encoding](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding) +- [Accept](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept) - `Accept.fromString()` returns the valid elements (media ranges in lowercase, with their parameters and weight) from the greatest weight, and `Accept.negotiate()` selects one of the offered media types like `request.accepts()` + +```typescript +import { Accept } from "binden"; + +const ranges = Accept.fromString(request.headers.accept); +const type = Accept.negotiate( + ["application/json", "text/html"], + request.headers.accept, +); +// or using BindenRequest +const preferred = request.accepts("application/json", "text/html"); +``` + +- [Accept-Charset](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Charset) + +```typescript +import { AcceptCharset } from "binden"; + +const charsets = AcceptCharset.fromString(request.headers["accept-charset"]); +const charset = AcceptCharset.negotiate( + ["utf-8", "iso-8859-1"], + request.headers["accept-charset"], +); +// or using BindenRequest +const preferred = request.acceptsCharsets("utf-8", "iso-8859-1"); +``` + +- [Accept-Encoding](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding) - the known codings (`gzip`, `x-gzip`, `compress`, `deflate`, `br`, `zstd`, `identity` and `*`). In `AcceptEncoding.negotiate()` (and `request.acceptsEncodings()`), `x-gzip` is `gzip`, unknown codings of the header count too, and `identity` is acceptable unless excluded (`identity;q=0`, or `*;q=0` without `identity`) ```typescript import { AcceptEncoding } from "binden"; const encodings = AcceptEncoding.fromString(request.headers["accept-encoding"]); +const encoding = AcceptEncoding.negotiate( + ["br", "gzip", "identity"], + request.headers["accept-encoding"], +); // or using BindenRequest const { accept_encoding } = request; +const preferred = request.acceptsEncodings("br", "gzip", "identity"); +``` + +- [Accept-Language](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language) - in `AcceptLanguage.negotiate()` (and `request.acceptsLanguages()`), a language range matches the tags that it is, or is a prefix of (`en` matches `en-US`, not the opposite) + +```typescript +import { AcceptLanguage } from "binden"; + +const languages = AcceptLanguage.fromString(request.headers["accept-language"]); +const language = AcceptLanguage.negotiate( + ["en-US", "fr"], + request.headers["accept-language"], +); +// or using BindenRequest +const preferred = request.acceptsLanguages("en-US", "fr"); ``` - [Authorization](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization) @@ -601,7 +802,7 @@ const credentials = Authorization.fromString(request.headers.authorization); const { authorization } = request; ``` -- [Content-Encoding](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding) - the codings in the order to remove them, or `null` when one of them is unknown or malformed (the content cannot be decoded then) +- [Content-Encoding](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding) - the codings (`gzip`, `x-gzip`, `compress`, `deflate`, `br` and `zstd`) in the order to remove them, or `null` when one of them is unknown or malformed (the content cannot be decoded then) ```typescript import { ContentEncoding } from "binden"; @@ -613,7 +814,7 @@ const encodings = ContentEncoding.fromString( const { content_encoding } = request; ``` -The constructors of the headers throw a `TypeError` for what their parsers reject (e.g. a reversed `Range`, a weight that is not a qvalue, or a `Content-Type` parameter with control characters). +The constructors of the headers throw a `TypeError` for what their parsers reject (e.g. a reversed `Range`, a weight that is not a qvalue, a `Content-Type` parameter with control characters, an unsupported `Authorization` scheme, an unknown content coding, or an Invalid Date). - [Content-Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range) - `bytes -/` (the size may be `*`) or an unsatisfied range, with decimal positions only @@ -657,7 +858,7 @@ const cookie2 = new Cookie({ response.cookies.add(cookie1).add(cookie2); ``` -- [Forwarded](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded) - `Forwarded.parse()` returns the elements in their positions (`null` for an invalid one: a parameter that is not `name=value`, or that is given twice), and `Forwarded.fromString()` the valid ones +- [Forwarded](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded) - `Forwarded.parse()` returns the elements in their positions (`null` for an invalid one: a parameter that is not `name=value`, that is given twice, or with control characters), and `Forwarded.fromString()` the valid ones. `.toString()` sends the values that are not tokens as quoted strings (`for="[2001:db8::1]:4711"`). These are the elements that a client sends too: `request.ip`, `.protocol` and `.host` read those of the [trusted proxies](#proxies) ```typescript import { Forwarded } from "binden"; diff --git a/packages/binden/src/headers/accept-charset.ts b/packages/binden/src/headers/accept-charset.ts index c4dfd8fa..d80672a3 100644 --- a/packages/binden/src/headers/accept-charset.ts +++ b/packages/binden/src/headers/accept-charset.ts @@ -1,5 +1,6 @@ import { isQvalue, preferred, token, weighted } from "./list.js"; +/** An element of `Accept-Charset` */ export interface IAcceptCharset { /** A charset (a token), or `*` */ charset: string; diff --git a/packages/binden/src/headers/accept-encoding.ts b/packages/binden/src/headers/accept-encoding.ts index dfd95258..85d42aab 100644 --- a/packages/binden/src/headers/accept-encoding.ts +++ b/packages/binden/src/headers/accept-encoding.ts @@ -1,13 +1,16 @@ import { preferred, qvalue, token } from "./list.js"; +/** The content codings of `Accept-Encoding` that are known (and `*`) */ export type IAcceptEncodings = "*" | "br" | "compress" | "deflate" | "gzip" | "identity" | "x-gzip" | "zstd"; +/** An element of `Accept-Encoding` */ export interface IAcceptEncoding { encoding: IAcceptEncodings; q_value?: number | null; } +/** An element of the `Accept-Encoding` header (RFC 9110, section 12.5.3) */ export class AcceptEncoding implements IAcceptEncoding { readonly #encoding: IAcceptEncodings; readonly #q_value: number | null; diff --git a/packages/binden/src/headers/accept-language.ts b/packages/binden/src/headers/accept-language.ts index e769ddfc..452e75db 100644 --- a/packages/binden/src/headers/accept-language.ts +++ b/packages/binden/src/headers/accept-language.ts @@ -1,5 +1,6 @@ import { isQvalue, preferred, weighted } from "./list.js"; +/** An element of `Accept-Language` */ export interface IAcceptLanguage { /** A language range: `*`, or a language tag (e.g. `en`, `en-US`) */ language: string; diff --git a/packages/binden/src/headers/accept.ts b/packages/binden/src/headers/accept.ts index c5f22bf8..9e75b8c4 100644 --- a/packages/binden/src/headers/accept.ts +++ b/packages/binden/src/headers/accept.ts @@ -7,6 +7,7 @@ import { weighted, } from "./list.js"; +/** An element of `Accept`: a media range, its parameters and weight */ export interface IAccept { /** A media range: `*\/*`, `type/*` or `type/subtype` */ type: string; diff --git a/packages/binden/src/headers/authorization.ts b/packages/binden/src/headers/authorization.ts index 2244d5e8..00cc42c5 100644 --- a/packages/binden/src/headers/authorization.ts +++ b/packages/binden/src/headers/authorization.ts @@ -1,5 +1,6 @@ import { fieldValue } from "./list.js"; +/** The supported authentication schemes, in their usual casing */ export type IAuthenticationType = | "AWS4-HMAC-SHA256" | "Basic" @@ -12,11 +13,13 @@ export type IAuthenticationType = | "SCRAM-SHA-256" | "vapid"; +/** The scheme and the credentials of `Authorization` */ export interface IAuthorization { type: IAuthenticationType; credentials?: string | null; } +/** The `Authorization` header (RFC 9110, section 11.6.2) */ export class Authorization implements IAuthorization { readonly #type: IAuthenticationType; readonly #credentials: string | null; diff --git a/packages/binden/src/headers/content-encoding.ts b/packages/binden/src/headers/content-encoding.ts index 3720c030..f1ae7f4d 100644 --- a/packages/binden/src/headers/content-encoding.ts +++ b/packages/binden/src/headers/content-encoding.ts @@ -1,10 +1,13 @@ +/** The known content codings */ export type IEncodings = "br" | "compress" | "deflate" | "gzip" | "x-gzip" | "zstd"; +/** A coding of `Content-Encoding` */ export interface IContentEncoding { encoding: IEncodings; } +/** A coding of the `Content-Encoding` header (RFC 9110, section 8.4) */ export class ContentEncoding implements IContentEncoding { readonly #encoding: IEncodings; diff --git a/packages/binden/src/headers/content-range.ts b/packages/binden/src/headers/content-range.ts index 8a1b8b57..642b760b 100644 --- a/packages/binden/src/headers/content-range.ts +++ b/packages/binden/src/headers/content-range.ts @@ -1,9 +1,11 @@ import { Range, type IRange } from "./range.js"; +/** The range of `Content-Range` (none when unsatisfied) and the size of the content */ export interface IContentRange extends IRange { readonly size: number | "*"; } +/** The `Content-Range` header (RFC 9110, section 14.4) */ export class ContentRange extends Range implements IContentRange { readonly #size: number | "*"; diff --git a/packages/binden/src/headers/content-type.ts b/packages/binden/src/headers/content-type.ts index 47d00433..cf20e94f 100644 --- a/packages/binden/src/headers/content-type.ts +++ b/packages/binden/src/headers/content-type.ts @@ -1,5 +1,6 @@ import { split, unquote } from "./list.js"; +/** A media type of `Content-Type` and its parameters */ export interface IContentType { type: string; charset?: string | null; @@ -20,6 +21,7 @@ const parameterName = new RegExp(`^${token}$`, "u"); // eslint-disable-next-line no-control-regex -- control characters on purpose const parameterValue = /^[^\u0000-\u0008\u000A-\u001F\u007F]*$/u; +/** The `Content-Type` header (RFC 9110, section 8.3) */ export class ContentType implements IContentType { readonly #type: string; readonly #charset: string | null; diff --git a/packages/binden/src/headers/cookie.ts b/packages/binden/src/headers/cookie.ts index b5b986c0..32c0b454 100644 --- a/packages/binden/src/headers/cookie.ts +++ b/packages/binden/src/headers/cookie.ts @@ -1,3 +1,4 @@ +/** A cookie: its name, value and attributes (RFC 6265) */ export interface ICookie { readonly key: string; readonly value: string; @@ -26,6 +27,7 @@ const domainValue = const sameSite: ReadonlySet = new Set(["Lax", "None", "Strict"]); +/** A cookie of `Cookie`, or of `Set-Cookie` with its attributes */ export class Cookie implements ICookie { readonly #key: string; readonly #value: string; diff --git a/packages/binden/src/headers/forwarded.ts b/packages/binden/src/headers/forwarded.ts index 7dc1d72b..28b48f3e 100644 --- a/packages/binden/src/headers/forwarded.ts +++ b/packages/binden/src/headers/forwarded.ts @@ -1,5 +1,6 @@ import { fieldValue, quote, split, unquote } from "./list.js"; +/** The parameters of a `Forwarded` element */ export interface IForwarded { for: string; by?: string | null; @@ -8,6 +9,7 @@ export interface IForwarded { secret?: string | null; } +/** An element of the `Forwarded` header (RFC 7239) */ export class Forwarded implements IForwarded { readonly #for: string; readonly #by?: string | null; diff --git a/packages/binden/src/headers/if-modified-since.ts b/packages/binden/src/headers/if-modified-since.ts index 878816f3..710da55f 100644 --- a/packages/binden/src/headers/if-modified-since.ts +++ b/packages/binden/src/headers/if-modified-since.ts @@ -1,9 +1,11 @@ import { parseHttpDate } from "./http-date.js"; +/** The date of `If-Modified-Since` */ export interface IIfModifiedSince { readonly date: Date; } +/** The `If-Modified-Since` header (RFC 9110, section 13.1.3) */ export class IfModifiedSince implements IIfModifiedSince { readonly #date: Date; diff --git a/packages/binden/src/headers/range.ts b/packages/binden/src/headers/range.ts index a00bc0b1..8cfceb7a 100644 --- a/packages/binden/src/headers/range.ts +++ b/packages/binden/src/headers/range.ts @@ -1,10 +1,13 @@ +/** A range of `Range`: its first and last positions, either one optional */ export interface IRange { readonly start?: number | null; readonly end?: number | null; } +/** The range units */ export type IUnit = "bytes"; +/** A range of the `Range` header (RFC 9110, section 14.2) */ export class Range implements IRange { readonly #start: number | null; readonly #end: number | null; diff --git a/packages/binden/src/request.ts b/packages/binden/src/request.ts index b5a1f792..7a268c64 100644 --- a/packages/binden/src/request.ts +++ b/packages/binden/src/request.ts @@ -25,8 +25,10 @@ import { strictBoolean, } from "./proxy.js"; +/** The trusted proxies (see `IRequestOptions.trust_proxy`) */ export type { IProxyHeaders, ITrustFn, ITrustProxy } from "./proxy.js"; +/** The protocol of a request, as in `URL.protocol` */ export type IProtocol = "http:" | "https:"; /** @@ -190,9 +192,9 @@ const incomingId = /^[\w.:-]{1,128}$/u; /** * The request options with their defaults, checked: a `TypeError` for a value - * of another type, a `RangeError` for a number out of its range (e.g. - * `trust_proxy: -1`, or `query.max_keys` that is not a positive integer or - * `Infinity`). Other properties of the input are ignored. + * of another type, a `RangeError` for a value out of its range (e.g. + * `trust_proxy: -1`, an empty `allowed_hosts`, or `query.max_keys` that is not + * a positive integer or `Infinity`). Other properties of the input are ignored. */ export function validateRequestOptions( input?: IRequestOptionsInput, @@ -227,6 +229,10 @@ export function validateRequestOptions( /** The default options of the requests */ const defaults = validateRequestOptions(); +/** + * An incoming request (`IncomingMessage`) with its parsed headers, URL, client + * and content negotiation: the `IncomingMessage` class of the servers + */ export class BindenRequest extends IncomingMessage { #accept_encoding?: readonly AcceptEncoding[]; #authorization?: Authorization | null; @@ -273,6 +279,7 @@ export class BindenRequest extends IncomingMessage { return AcceptLanguage.negotiate(languages, this.headers["accept-language"]); } + /** The known codings of `Accept-Encoding`, from the greatest weight */ public get accept_encoding(): AcceptEncoding[] { if (!this.#accept_encoding) { const ae = this.headers["accept-encoding"]; @@ -281,6 +288,7 @@ export class BindenRequest extends IncomingMessage { return [...this.#accept_encoding]; } + /** The `Authorization` header, or `null` (unsupported or missing) */ public get authorization(): Authorization | null { if (typeof this.#authorization === "undefined") { const { authorization } = this.headers; @@ -289,6 +297,7 @@ export class BindenRequest extends IncomingMessage { return this.#authorization; } + /** The parsed body (set once, e.g. by a body parser) */ public get body(): unknown { return this.#body; } @@ -309,6 +318,7 @@ export class BindenRequest extends IncomingMessage { return this.#content_encoding === null ? null : [...this.#content_encoding]; } + /** The `Content-Type` header, or `null` (invalid or missing) */ public get content_type(): ContentType | null { if (typeof this.#content_type === "undefined") { this.#content_type = ContentType.fromString(this.headers["content-type"]); @@ -316,6 +326,7 @@ export class BindenRequest extends IncomingMessage { return this.#content_type; } + /** The valid cookies of the `Cookie` header */ public get cookies(): Cookie[] { if (!this.#cookies) { this.#cookies = Cookie.fromString(this.headers.cookie); @@ -323,6 +334,10 @@ export class BindenRequest extends IncomingMessage { return [...this.#cookies]; } + /** + * The valid elements of `Forwarded`, from any client (see `ip` and + * `protocol` for those of the trusted proxies) + */ public get forwarded(): Forwarded[] { if (!this.#forwarded) { this.#forwarded = Forwarded.fromString(this.headers.forwarded); @@ -330,6 +345,7 @@ export class BindenRequest extends IncomingMessage { return [...this.#forwarded]; } + /** A header by its name, in any case */ public header(name: string): string[] | string | undefined { return this.headers[name.toLowerCase()]; } @@ -344,6 +360,7 @@ export class BindenRequest extends IncomingMessage { return this.#id; } + /** The `If-Modified-Since` header, or `null` (not an HTTP-date, or missing) */ public get if_modified_since(): IfModifiedSince | null { if (typeof this.#if_modified_since === "undefined") { const input = this.headers["if-modified-since"]; @@ -437,6 +454,7 @@ export class BindenRequest extends IncomingMessage { ); } + /** The ranges of the `Range` header */ public get range(): Range[] { if (typeof this.#range === "undefined") { this.#range = Range.fromString(this.headers.range); From c574ae08eb58e250e0a847898aedbb4f5b152c3d Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:12:18 +0300 Subject: [PATCH 121/675] fix: send text() and html() as UTF-8 in binden The strings of text() and html() are always encoded as UTF-8, but their Content-Type had no charset: clients guessed it (often as ISO-8859-1 for text/plain), which garbled non-ASCII text. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/response.ts | 13 ++++++++----- packages/binden/test/response.test.ts | 10 ++++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index 81b3b447..6f84e063 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -179,14 +179,18 @@ export class BindenResponse< return this.setHeader("Content-Type", ct_json).send(msg); } - /** Send response as `text/plain` */ + /** Send response as `text/plain; charset=utf-8` */ public text(data: string): Promise { - return this.setHeader("Content-Type", ct_text).send(data); + return this.setHeader("Content-Type", `${ct_text}; charset=utf-8`).send( + data, + ); } - /** Send response as `text/html` */ + /** Send response as `text/html; charset=utf-8` */ public html(data: string): Promise { - return this.setHeader("Content-Type", ct_html).send(data); + return this.setHeader("Content-Type", `${ct_html}; charset=utf-8`).send( + data, + ); } /** Send response as `application/x-www-form-urlencoded` */ @@ -194,7 +198,6 @@ export class BindenResponse< return this.setHeader("Content-Type", ct_form).send(params.toString()); } - /** Send a file */ /** A response without a body: to `HEAD`, or with a 1xx, 204, 205 or 304 status */ #bodiless(): boolean { return ( diff --git a/packages/binden/test/response.test.ts b/packages/binden/test/response.test.ts index 32b21dc1..486b7de2 100644 --- a/packages/binden/test/response.test.ts +++ b/packages/binden/test/response.test.ts @@ -949,7 +949,10 @@ suite("BindenResponse", () => { await serverPromise; const data = await response.text(); - deepStrictEqual(response.headers.get("Content-Type"), ct_text); + deepStrictEqual( + response.headers.get("Content-Type"), + `${ct_text}; charset=utf-8`, + ); deepStrictEqual(data, text); }); @@ -964,7 +967,10 @@ suite("BindenResponse", () => { await serverPromise; const data = await response.text(); - deepStrictEqual(response.headers.get("Content-Type"), ct_html); + deepStrictEqual( + response.headers.get("Content-Type"), + `${ct_html}; charset=utf-8`, + ); deepStrictEqual(data, html); }); From 8cd530bab57836ffbce228793f4d4289d369093b Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:12:49 +0300 Subject: [PATCH 122/675] feat!: send any JSON value and reject the others in binden json() accepted only objects and arrays in its types, and sent an empty body for a value without a JSON text (`JSON.stringify()` returns undefined for undefined, a function or a symbol). It now takes any JSON value (null, numbers, strings, booleans too), and rejects undefined, a function, a symbol, a bigint, a cyclic value, or a custom stringify that does not return a string with a TypeError before the response is started, so that the error handler can still reply. Its type parameter lets a typed stringify check the data. BREAKING CHANGE: `response.json()` rejects with a `TypeError` for a value without a JSON text (e.g. `undefined`) instead of sending an empty body; send `null` or an explicit status instead. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/response.ts | 31 ++++- packages/binden/test/http.ts | 120 ++++++++++++++++++ packages/binden/test/response-helpers.test.ts | 112 ++++++++++++++++ 3 files changed, 256 insertions(+), 7 deletions(-) create mode 100644 packages/binden/test/http.ts create mode 100644 packages/binden/test/response-helpers.test.ts diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index 6f84e063..bc3275e2 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -169,14 +169,31 @@ export class BindenResponse< }); } - /** Send response as `application/json` */ - public async json( - data: Record | unknown[], - stringify = (input: Record | unknown[]): string => - JSON.stringify(input), + /** + * Send a JSON value as `application/json`. A value that has no JSON text + * (`undefined`, a function or a symbol), a `bigint` or a cyclic value + * rejects with a `TypeError` before the response is started, as does a + * custom `stringify` that does not return a string. + */ + public async json( + data: T, + stringify?: (data: T) => string, ): Promise { - const msg = stringify(data); - return this.setHeader("Content-Type", ct_json).send(msg); + const type = typeof data; + if ( + type === "undefined" || + type === "function" || + type === "symbol" || + type === "bigint" + ) { + throw new TypeError(`A ${type} is not a JSON value`); + } + const body: unknown = + typeof stringify === "undefined" ? JSON.stringify(data) : stringify(data); + if (typeof body !== "string") { + throw new TypeError("The JSON text is not a string"); + } + await this.setHeader("Content-Type", ct_json).send(body); } /** Send response as `text/plain; charset=utf-8` */ diff --git a/packages/binden/test/http.ts b/packages/binden/test/http.ts new file mode 100644 index 00000000..f8ddb56b --- /dev/null +++ b/packages/binden/test/http.ts @@ -0,0 +1,120 @@ +import { once } from "node:events"; +import { + type IncomingHttpHeaders, + IncomingMessage, + type OutgoingHttpHeaders, + type Server, + createServer, + request as httpRequest, +} from "node:http"; +import type { AddressInfo } from "node:net"; + +import { BindenRequest, BindenResponse } from "../index.js"; + +/** A response as the client receives it */ +export interface IReply { + readonly status: number; + readonly headers: IncomingHttpHeaders; + readonly body: Buffer; +} + +export interface IRequestOptions { + readonly method?: string; + readonly path?: string; + readonly headers?: OutgoingHttpHeaders; +} + +export type IHandler = ( + request: IncomingMessage, + response: BindenResponse, +) => unknown; + +/** A server of `BindenResponse` (and `BindenRequest`, unless `plain`) */ +export interface ITestServer { + readonly url: string; + /** + * Handles the next request: settles with `null` once the handler has + * settled, or with what it has thrown (never rejects) + */ + handle(handler: IHandler): Promise; + request(options?: IRequestOptions): Promise; + close(): Promise; +} + +export interface IListenOptions { + /** `IncomingMessage` instead of `BindenRequest` */ + readonly plain?: boolean; + readonly Response?: new (request: IncomingMessage) => BindenResponse; +} + +/** Starts a server on a random port of `localhost` */ +export async function listen({ + plain = false, + Response = BindenResponse, +}: IListenOptions = {}): Promise { + const handlers: [IHandler, (outcome: unknown) => void][] = []; + const server: Server = createServer({ + ServerResponse: Response as typeof BindenResponse, + ...(plain ? {} : { IncomingMessage: BindenRequest }), + }); + server.on("request", (request: IncomingMessage, response: BindenResponse) => { + const next = handlers.shift(); + if (typeof next === "undefined") { + response.statusCode = 500; + response.end(); + return; + } + const [handler, settle] = next; + new Promise((resolve) => { + resolve(handler(request, response)); + }).then( + () => { + settle(null); + }, + (error: unknown) => { + settle(error); + }, + ); + }); + server.listen(0, "localhost"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const url = `http://localhost:${port}`; + + return { + url, + handle: (handler) => + new Promise((resolve) => { + handlers.push([handler, resolve]); + }), + request: ({ method = "GET", path = "/", headers = {} } = {}) => + new Promise((resolve, reject) => { + const req = httpRequest(`${url}${path}`, { + method, + headers, + signal: AbortSignal.timeout(5000), + }); + req + .on("response", (response) => { + const chunks: Buffer[] = []; + response + .on("data", (chunk: Buffer) => chunks.push(chunk)) + .once("end", () => { + resolve({ + status: response.statusCode ?? 0, + headers: response.headers, + body: Buffer.concat(chunks), + }); + }) + .once("error", reject); + }) + .once("error", reject) + .end(); + }), + close: async (): Promise => { + server.closeAllConnections(); + server.close(); + await once(server, "close"); + }, + }; +} diff --git a/packages/binden/test/response-helpers.test.ts b/packages/binden/test/response-helpers.test.ts new file mode 100644 index 00000000..19272b8c --- /dev/null +++ b/packages/binden/test/response-helpers.test.ts @@ -0,0 +1,112 @@ +/* eslint-disable init-declarations */ +import { deepStrictEqual, ok } from "node:assert"; +import { IncomingMessage } from "node:http"; +import { Socket } from "node:net"; +import { afterEach, beforeEach, suite, test } from "node:test"; + +import { BindenResponse, ct_json } from "../index.js"; +import { type ITestServer, listen } from "./http.js"; + +/** `undefined`, which the linter does not let the tests write */ +const none = ({} as { value?: undefined }).value; + +/** A response without a connection: nothing is actually sent */ +function detached(): BindenResponse { + return new BindenResponse(new IncomingMessage(new Socket())); +} + +suite("BindenResponse helpers", () => { + let server: ITestServer; + + beforeEach(async () => { + server = await listen(); + }); + + afterEach(async () => { + await server.close(); + }); + + test(".json() (any JSON value)", async () => { + for (const value of [null, 0, -1.5, "text", true, [1, "2"], { a: [] }]) { + const handled = server.handle((_request, response) => + response.json(value), + ); + const { headers, body } = await server.request(); + deepStrictEqual(await handled, null); + deepStrictEqual(headers["content-type"], ct_json); + deepStrictEqual(JSON.parse(body.toString()), value); + } + }); + + test(".json() (rejects what has no JSON text before the response)", async () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + const values: [unknown, RegExp][] = [ + [none, /^TypeError: A undefined is not a JSON value$/u], + [(): number => 1, /^TypeError: A function is not a JSON value$/u], + [Symbol("s"), /^TypeError: A symbol is not a JSON value$/u], + [1n, /^TypeError: A bigint is not a JSON value$/u], + // Nested, by `JSON.stringify()` + [{ big: 1n }, /^TypeError: Do not know how to serialize a BigInt$/u], + [cyclic, /^TypeError: Converting circular structure to JSON/u], + // `toJSON()` without a value + [{ toJSON: (): undefined => none }, /^TypeError: The JSON text/u], + ]; + for (const [value, error] of values) { + const response = detached(); + const sent = await response.json(value).then( + () => null, + (reason: unknown) => reason, + ); + ok(error.test(String(sent)), String(sent)); + // Nothing is set nor started: the error can be replied + ok(!response.started); + ok(!response.hasHeader("Content-Type")); + } + }); + + test(".json() (a custom `stringify` must return a string)", async () => { + const response = detached(); + const sent = await response + .json({ a: 1 }, () => 1 as unknown as string) + .then( + () => null, + (reason: unknown) => reason, + ); + deepStrictEqual(sent, new TypeError("The JSON text is not a string")); + ok(!response.started); + + // Its own errors, and the values it accepts (e.g. a `bigint`) + const failing = new Error("stringify"); + const rejected = await detached() + .json({}, () => { + throw failing; + }) + .then( + () => null, + (reason: unknown) => reason, + ); + deepStrictEqual(rejected, failing); + + const handled = server.handle((_request, res) => + res.json({ n: 1n }, (data) => `{"n":${String(data.n)}}`), + ); + const { body } = await server.request(); + deepStrictEqual(await handled, null); + deepStrictEqual(body.toString(), `{"n":1}`); + }); + + test(".text() and .html() (UTF-8)", async () => { + const text = server.handle((_request, response) => response.text("😀")); + const plain = await server.request(); + deepStrictEqual(await text, null); + deepStrictEqual(plain.headers["content-type"], "text/plain; charset=utf-8"); + deepStrictEqual(plain.body.toString(), "😀"); + + const html = server.handle((_request, response) => response.html("

")); + const page = await server.request(); + deepStrictEqual(await html, null); + deepStrictEqual(page.headers["content-type"], "text/html; charset=utf-8"); + deepStrictEqual(page.body.toString(), "

"); + }); +}); From f7a032c4050f18c4ff009e34ed7d69450afeb53f Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:23:19 +0300 Subject: [PATCH 123/675] feat: add type(), attachment() and vary() to the response in binden These headers are easy to get wrong by hand: type() validates and normalizes a media type, attachment() writes an ASCII filename with its UTF-8 filename* (RFC 6266 and RFC 8187) so that no character can change the header, and vary() merges field names once each, as @binden/compression does, keeping `*`. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/response.ts | 82 +++++++++++++++++ packages/binden/test/response-helpers.test.ts | 89 ++++++++++++++++++- 2 files changed, 170 insertions(+), 1 deletion(-) diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index bc3275e2..5c3fadea 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -8,6 +8,7 @@ import { import { Readable } from "node:stream"; import { pathToFileURL } from "node:url"; import ContentRange from "./headers/content-range.js"; +import ContentType from "./headers/content-type.js"; import Cookie from "./headers/cookie.js"; import IfModifiedSince from "./headers/if-modified-since.js"; import { parseHttpDate } from "./headers/http-date.js"; @@ -25,6 +26,38 @@ export type IHeadersValue = number | string | readonly string[]; export type IHeaders = Record; +/** The characters of `attr-char` (RFC 8187, section 3.2.1) */ +const attrChar = /^[\w!#$&+.^`|~-]$/u; + +/** + * The `Content-Disposition` of an attachment (RFC 6266): an ASCII `filename` + * (other characters replaced with `_`) and the UTF-8 `filename*` (RFC 8187) + */ +function contentDisposition(filename?: string): string { + if (typeof filename === "undefined") { + return "attachment"; + } else if ( + typeof filename !== "string" || + filename === "" || + !filename.isWellFormed() + ) { + throw new TypeError("The filename is not a non-empty well-formed string"); + } + const ascii = filename.replaceAll(/[^\x20-\x7E]|["\\]/gu, "_"); + const encoded = [...new TextEncoder().encode(filename)] + .map((byte) => { + const char = String.fromCharCode(byte); + return attrChar.test(char) + ? char + : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`; + }) + .join(""); + return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`; +} + +/** A header field name (a token, RFC 9110, section 5.6.2) */ +const fieldName = /^[!#$%&'*+\-.^_`|~\dA-Za-z]+$/u; + export class BindenResponse< Request extends IncomingMessage = IncomingMessage, > extends ServerResponse { @@ -72,6 +105,55 @@ export class BindenResponse< return this; } + /** Set the `Content-Type`: a media type with its parameters (a `TypeError` otherwise) */ + public type(media_type: string): this { + const parsed = ContentType.fromString(media_type); + if (parsed === null) { + throw new TypeError(`Invalid media type: ${media_type}`); + } + return this.setHeader("Content-Type", parsed.toString()); + } + + /** + * Send the response as an attachment (`Content-Disposition`), with the name + * to save it as: in ASCII (`filename`) and in UTF-8 (`filename*`, RFC 8187) + */ + public attachment(filename?: string): this { + return this.setHeader("Content-Disposition", contentDisposition(filename)); + } + + /** + * Add the names of request headers that select the response to `Vary` + * (once each, case-insensitively); `*` replaces them all + */ + public vary(...fields: string[]): this { + for (const field of fields) { + if (!fieldName.test(field)) { + throw new TypeError(`Invalid field name: ${field}`); + } + } + const raw = this.getHeader("Vary"); + const values = (Array.isArray(raw) ? raw : [`${raw ?? ""}`]) + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter((value) => value !== ""); + const names = new Set(values.map((value) => value.toLowerCase())); + if (names.has("*")) { + return this; + } else if (fields.includes("*")) { + return this.setHeader("Vary", "*"); + } + for (const field of fields) { + if (!names.has(field.toLowerCase())) { + names.add(field.toLowerCase()); + values.push(field); + } + } + return values.length === 0 + ? this + : this.setHeader("Vary", values.join(", ")); + } + /** Send a response */ public send( data?: Buffer | Readable | bigint | number | string, diff --git a/packages/binden/test/response-helpers.test.ts b/packages/binden/test/response-helpers.test.ts index 19272b8c..e0722776 100644 --- a/packages/binden/test/response-helpers.test.ts +++ b/packages/binden/test/response-helpers.test.ts @@ -1,5 +1,5 @@ /* eslint-disable init-declarations */ -import { deepStrictEqual, ok } from "node:assert"; +import { deepStrictEqual, ok, throws } from "node:assert"; import { IncomingMessage } from "node:http"; import { Socket } from "node:net"; import { afterEach, beforeEach, suite, test } from "node:test"; @@ -109,4 +109,91 @@ suite("BindenResponse helpers", () => { deepStrictEqual(page.headers["content-type"], "text/html; charset=utf-8"); deepStrictEqual(page.body.toString(), "

"); }); + + test(".type()", () => { + const response = detached(); + ok(response.type(" Text/HTML ; Charset=UTF-8; q=x") === response); + deepStrictEqual( + response.getHeader("Content-Type"), + "text/html; charset=utf-8; q=x", + ); + for (const invalid of ["text", "text/html\r\nX-Injected: 1", "", "a b/c"]) { + throws( + () => response.type(invalid), + new TypeError(`Invalid media type: ${invalid}`), + ); + } + deepStrictEqual( + response.getHeader("Content-Type"), + "text/html; charset=utf-8; q=x", + ); + }); + + test(".attachment()", () => { + const response = detached(); + ok(response.attachment() === response); + deepStrictEqual(response.getHeader("Content-Disposition"), "attachment"); + + const cases: [string, string, string][] = [ + ["report.pdf", "report.pdf", "report.pdf"], + // Not `attr-char` (RFC 8187): percent-encoded + ["a b'(c)*%.txt", "a b'(c)*%.txt", "a%20b%27%28c%29%2A%25.txt"], + // Not in a quoted string, nor ASCII + [`"q"\\.txt`, "_q__.txt", "%22q%22%5C.txt"], + [ + "отчёт €.csv", + "_____ _.csv", + "%D0%BE%D1%82%D1%87%D1%91%D1%82%20%E2%82%AC.csv", + ], + ["tab\t\r\n.txt", "tab___.txt", "tab%09%0D%0A.txt"], + ["!#$&+-.^_`|~", "!#$&+-.^_`|~", "!#$&+-.^_`|~"], + ]; + for (const [name, ascii, encoded] of cases) { + response.attachment(name); + deepStrictEqual( + response.getHeader("Content-Disposition"), + `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`, + ); + } + + for (const invalid of ["", "\uD800.txt", 1 as unknown as string]) { + throws( + () => response.attachment(invalid), + new TypeError("The filename is not a non-empty well-formed string"), + ); + } + }); + + test(".vary()", () => { + const response = detached(); + ok(response.vary() === response); + ok(!response.hasHeader("Vary")); + + response.vary("Accept-Encoding"); + deepStrictEqual(response.getHeader("Vary"), "Accept-Encoding"); + // Once each, case-insensitively, after the values set before + response.setHeader("Vary", ["Origin, accept-encoding", " ", "Cookie"]); + response.vary("Accept-Encoding", "Accept", "ACCEPT", "Cookie"); + deepStrictEqual( + response.getHeader("Vary"), + "Origin, accept-encoding, Cookie, Accept", + ); + response.setHeader("Vary", "Origin"); + response.vary(); + deepStrictEqual(response.getHeader("Vary"), "Origin"); + + // `*` replaces them all, and stays + response.vary("Accept", "*"); + deepStrictEqual(response.getHeader("Vary"), "*"); + response.vary("Accept"); + deepStrictEqual(response.getHeader("Vary"), "*"); + + for (const invalid of ["", "A B", "X\r\nY: z", "a,b"]) { + throws( + () => response.vary("Accept", invalid), + new TypeError(`Invalid field name: ${invalid}`), + ); + } + deepStrictEqual(response.getHeader("Vary"), "*"); + }); }); From ad5928bd49eb05b76bd3384f7a1c1d1374a333c6 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:24:14 +0300 Subject: [PATCH 124/675] feat: add clearCookie() and partitioned cookies in binden Removing a cookie needs the same name, domain, path and partition as the cookie itself: clearCookie() sends an empty value that expires at once with those attributes, replacing a cookie of the same name, domain and path set on the response. Cookies get the Partitioned attribute (CHIPS), which implies Secure as browsers require. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/headers/cookie.ts | 11 +++++ packages/binden/src/response.ts | 33 ++++++++++++- packages/binden/test/response-helpers.test.ts | 49 ++++++++++++++++++- 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/packages/binden/src/headers/cookie.ts b/packages/binden/src/headers/cookie.ts index 32c0b454..6665f77e 100644 --- a/packages/binden/src/headers/cookie.ts +++ b/packages/binden/src/headers/cookie.ts @@ -9,6 +9,8 @@ export interface ICookie { readonly secure?: boolean; readonly http_only?: boolean; readonly same_site?: "Lax" | "None" | "Strict"; + /** Partitioned by the top-level site (CHIPS): implies `Secure` */ + readonly partitioned?: boolean; } /** A token (RFC 9110, section 5.6.2) */ @@ -38,6 +40,7 @@ export class Cookie implements ICookie { readonly #secure: boolean; readonly #http_only: boolean; readonly #same_site: "Lax" | "None" | "Strict"; + readonly #partitioned: boolean; public constructor({ key, @@ -49,6 +52,7 @@ export class Cookie implements ICookie { secure = false, http_only = true, same_site = "Lax", + partitioned = false, }: ICookie) { // An invalid name or attribute could add attributes to `Set-Cookie` if (!token.test(key)) { @@ -75,6 +79,7 @@ export class Cookie implements ICookie { this.#secure = Boolean(secure); this.#http_only = Boolean(http_only); this.#same_site = same_site; + this.#partitioned = Boolean(partitioned); this.#expires = expires instanceof Date ? new Date(expires) : null; } @@ -107,6 +112,7 @@ export class Cookie implements ICookie { this.key.startsWith("__Secure-") || this.key.startsWith("__Host-") || this.same_site === "None" || + this.#partitioned || this.#secure ); } @@ -119,6 +125,10 @@ export class Cookie implements ICookie { return this.#same_site; } + public get partitioned(): boolean { + return this.#partitioned; + } + public toString(): string { let output = `${this.key}=${this.value}`; output += @@ -132,6 +142,7 @@ export class Cookie implements ICookie { output += this.secure ? `; Secure` : ""; output += this.http_only ? `; HttpOnly` : ""; output += `; SameSite=${this.same_site}`; + output += this.partitioned ? "; Partitioned" : ""; return output; } diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index 5c3fadea..c2c5ae6d 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -9,7 +9,7 @@ import { Readable } from "node:stream"; import { pathToFileURL } from "node:url"; import ContentRange from "./headers/content-range.js"; import ContentType from "./headers/content-type.js"; -import Cookie from "./headers/cookie.js"; +import Cookie, { type ICookie } from "./headers/cookie.js"; import IfModifiedSince from "./headers/if-modified-since.js"; import { parseHttpDate } from "./headers/http-date.js"; import { split } from "./headers/list.js"; @@ -26,6 +26,12 @@ export type IHeadersValue = number | string | readonly string[]; export type IHeaders = Record; +/** The attributes of a cookie to remove (see `BindenResponse.clearCookie()`) */ +export type IClearCookie = Pick< + ICookie, + "domain" | "partitioned" | "path" | "same_site" | "secure" +>; + /** The characters of `attr-char` (RFC 8187, section 3.2.1) */ const attrChar = /^[\w!#$&+.^`|~-]$/u; @@ -154,6 +160,31 @@ export class BindenResponse< : this.setHeader("Vary", values.join(", ")); } + /** + * Remove a cookie from the client: an empty value that expires at once, + * with the attributes (`domain`, `path`, …) of the cookie to remove. It + * replaces a cookie of the same name, domain and path set on the response. + */ + public clearCookie(name: string, attributes: IClearCookie = {}): this { + const cookie = new Cookie({ + ...attributes, + key: name, + value: "", + max_age: 0, + }); + for (const other of this.cookies) { + if ( + other.key === cookie.key && + other.domain === cookie.domain && + other.path === cookie.path + ) { + this.cookies.delete(other); + } + } + this.cookies.add(cookie); + return this; + } + /** Send a response */ public send( data?: Buffer | Readable | bigint | number | string, diff --git a/packages/binden/test/response-helpers.test.ts b/packages/binden/test/response-helpers.test.ts index e0722776..f4cf3c75 100644 --- a/packages/binden/test/response-helpers.test.ts +++ b/packages/binden/test/response-helpers.test.ts @@ -4,7 +4,7 @@ import { IncomingMessage } from "node:http"; import { Socket } from "node:net"; import { afterEach, beforeEach, suite, test } from "node:test"; -import { BindenResponse, ct_json } from "../index.js"; +import { BindenResponse, Cookie, ct_json } from "../index.js"; import { type ITestServer, listen } from "./http.js"; /** `undefined`, which the linter does not let the tests write */ @@ -196,4 +196,51 @@ suite("BindenResponse helpers", () => { } deepStrictEqual(response.getHeader("Vary"), "*"); }); + + test(".clearCookie()", async () => { + const handled = server.handle(async (_request, response) => { + response.cookies + .add(new Cookie({ key: "session", value: "1" })) + .add(new Cookie({ key: "session", value: "2", path: "/app" })) + .add(new Cookie({ key: "other", value: "3" })); + ok(response.clearCookie("session") === response); + response.clearCookie("chips", { + domain: "example.com", + path: "/x", + same_site: "None", + partitioned: true, + }); + response.clearCookie("plain", { secure: true, same_site: "Strict" }); + await response.send(); + }); + const { headers } = await server.request(); + deepStrictEqual(await handled, null); + deepStrictEqual(headers["set-cookie"], [ + // Another path: another cookie + "session=2; Path=/app; HttpOnly; SameSite=Lax", + "other=3; Path=/; HttpOnly; SameSite=Lax", + // Replaces the cookie of the same name, domain and path + "session=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax", + "chips=; Max-Age=0; Domain=example.com; Path=/x; Secure; HttpOnly; SameSite=None; Partitioned", + "plain=; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=Strict", + ]); + + throws( + () => detached().clearCookie("a b"), + new TypeError("Invalid cookie name: a b"), + ); + }); + + test("Cookie (`partitioned` implies `Secure`)", () => { + const cookie = new Cookie({ key: "k", value: "v", partitioned: true }); + deepStrictEqual(cookie.partitioned, true); + deepStrictEqual(cookie.secure, true); + deepStrictEqual( + cookie.toString(), + "k=v; Path=/; Secure; HttpOnly; SameSite=Lax; Partitioned", + ); + const unpartitioned = new Cookie({ key: "k", value: "v" }); + deepStrictEqual(unpartitioned.partitioned, false); + deepStrictEqual(unpartitioned.secure, false); + }); }); From b191fa15ef0a49b3c2cdafc28e2bfd978b51f0d2 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:24:45 +0300 Subject: [PATCH 125/675] fix: add the cookies of the response when its headers are sent in binden The cookies of `response.cookies` were added by send() only: a response sent by writeHead(), write(), end(), flushHeaders() or a piped stream (e.g. by compression or an error handler that writes directly) lost them. They are now added once by writeHead(), which every path calls, after the Set-Cookie values that it is given (or else that were set before). When writeHead() throws, the header is restored for the next attempt. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/response.ts | 128 ++++++++++-- packages/binden/test/response-commit.test.ts | 198 +++++++++++++++++++ 2 files changed, 312 insertions(+), 14 deletions(-) create mode 100644 packages/binden/test/response-commit.test.ts diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index c2c5ae6d..81dfe311 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -3,6 +3,8 @@ import { open } from "node:fs/promises"; import { type IncomingHttpHeaders, IncomingMessage, + type OutgoingHttpHeader, + type OutgoingHttpHeaders, ServerResponse, } from "node:http"; import { Readable } from "node:stream"; @@ -79,6 +81,10 @@ export class BindenResponse< }); } + /** + * The cookies to send: they are added to the `Set-Cookie` header (after the + * values that it has) once, when the headers are sent + */ public get cookies(): Set { if (!this.#cookies) { this.#cookies = new Set(); @@ -185,6 +191,114 @@ export class BindenResponse< return this; } + /** + * Sends the headers (with the cookies of `.cookies`, added once to the + * `Set-Cookie` header: after the values given here or else set before) by + * every path: explicitly, or by the first `write()`, `end()`, + * `flushHeaders()` or piped chunk + */ + public override writeHead( + statusCode: number, + statusMessage?: string, + headers?: OutgoingHttpHeader[] | OutgoingHttpHeaders, + ): this; + public override writeHead( + statusCode: number, + headers?: OutgoingHttpHeader[] | OutgoingHttpHeaders, + ): this; + public override writeHead( + statusCode: number, + reason?: OutgoingHttpHeader[] | OutgoingHttpHeaders | string, + headers?: OutgoingHttpHeader[] | OutgoingHttpHeaders, + ): this { + // As `writeHead()` of Node.js: the headers follow a reason phrase, or are + // the second argument + const given = typeof reason === "string" ? headers : (headers ?? reason); + const write = ( + fields: OutgoingHttpHeader[] | OutgoingHttpHeaders | undefined, + ): this => { + if (typeof reason === "string") { + return super.writeHead(statusCode, reason, fields); + } + return super.writeHead(statusCode, fields); + }; + if (this.headersSent || this.cookies.size === 0) { + return write(given); + } + + const previous = this.getHeader("Set-Cookie"); + const [explicit, rest] = BindenResponse.#takeSetCookie(given); + const values = [...this.cookies].map((cookie) => cookie.toString()); + this.setHeader("Set-Cookie", [ + ...(explicit ?? BindenResponse.#values(previous)), + ...values, + ]); + try { + return write(rest); + } catch (error: unknown) { + // Not sent (e.g. an invalid status code): the next attempt adds them + if (typeof previous === "undefined") { + this.removeHeader("Set-Cookie"); + } else { + this.setHeader("Set-Cookie", previous); + } + throw error; + } + } + + /** + * The `Set-Cookie` values of the headers given to `writeHead()` (`null` + * without one), and the other headers. Nested pairs (`[[name, value]]`) are + * flattened, as the headers set before are merged with them. + */ + static #takeSetCookie( + headers: OutgoingHttpHeader[] | OutgoingHttpHeaders | undefined, + ): [string[] | null, OutgoingHttpHeader[] | OutgoingHttpHeaders | undefined] { + let explicit: string[] | null = null; + const take = ( + name: OutgoingHttpHeader, + value: OutgoingHttpHeader | undefined, + ): boolean => { + if (typeof name === "string" && name.toLowerCase() === "set-cookie") { + explicit = [...(explicit ?? []), ...BindenResponse.#values(value)]; + return true; + } + return false; + }; + if (Array.isArray(headers)) { + const flat = Array.isArray(headers[0]) ? headers.flat() : headers; + const rest: OutgoingHttpHeader[] = []; + for (let index = 0; index < flat.length; index += 2) { + const [name, value] = flat.slice(index, index + 2) as [ + OutgoingHttpHeader, + OutgoingHttpHeader, + ]; + if (!take(name, value)) { + rest.push(name, value); + } + } + return [explicit, rest]; + } else if (headers) { + const rest: OutgoingHttpHeaders = {}; + for (const [name, value] of Object.entries(headers)) { + if (!take(name, value)) { + rest[name] = value; + } + } + return [explicit, rest]; + } + return [null, headers]; + } + + /** The values of a header as strings */ + static #values(value: OutgoingHttpHeader | undefined): string[] { + return Array.isArray(value) + ? value.map(String) + : typeof value === "undefined" + ? [] + : [String(value)]; + } + /** Send a response */ public send( data?: Buffer | Readable | bigint | number | string, @@ -204,20 +318,6 @@ export class BindenResponse< this.#started = true; - if (!this.headersSent && this.cookies.size) { - const setCookie = this.getHeader("Set-Cookie"); - const values = [...this.cookies].map((c) => c.toString()); - - this.setHeader( - "Set-Cookie", - typeof setCookie === "undefined" - ? values - : Array.isArray(setCookie) - ? [...setCookie, ...values] - : [`${setCookie}`, ...values], - ); - } - if (data instanceof Readable) { if (this.#bodiless()) { // Nothing of the source would be sent: it is not read at all diff --git a/packages/binden/test/response-commit.test.ts b/packages/binden/test/response-commit.test.ts new file mode 100644 index 00000000..569892d5 --- /dev/null +++ b/packages/binden/test/response-commit.test.ts @@ -0,0 +1,198 @@ +import { deepStrictEqual, ok, throws } from "node:assert"; +import { type OutgoingHttpHeader, type OutgoingHttpHeaders } from "node:http"; +import { Readable } from "node:stream"; +import { afterEach, suite, test } from "node:test"; + +import { BindenResponse, Cookie } from "../index.js"; +import { type ITestServer, type IListenOptions, listen } from "./http.js"; + +/** `undefined`, which the linter does not let the tests write */ +const none = ({} as { value?: undefined }).value; + +const cookie1 = new Cookie({ key: "c1", value: "1" }); +const cookie2 = new Cookie({ key: "c2", value: "2" }); +const cookies = [cookie1.toString(), cookie2.toString()]; + +suite("BindenResponse (headers, flush, ETag and seal)", () => { + let server: ITestServer | null = null; + + const start = async (options?: IListenOptions): Promise => { + server = await listen(options); + return server; + }; + + afterEach(async () => { + const current = server; + server = null; + await current?.close(); + }); + + test("cookies are added once, by every `writeHead()` overload", async () => { + const app = await start(); + type IHeadersArg = OutgoingHttpHeader[] | OutgoingHttpHeaders; + const cases: [ + string, + (response: BindenResponse) => void, + string[], + Record, + ][] = [ + ["status only", (r): unknown => r.writeHead(200), cookies, {}], + [ + "set before", + (r): unknown => r.setHeader("Set-Cookie", "a=1").writeHead(201), + ["a=1", ...cookies], + {}, + ], + [ + "an object, which replaces the value set before", + (r): unknown => + r + .setHeader("Set-Cookie", "old=0") + .writeHead(200, { "Set-Cookie": "a=1", "X-A": "1" }), + ["a=1", ...cookies], + { "x-a": "1" }, + ], + [ + "a reason phrase and an object", + (r): unknown => + r.writeHead(200, "Fine", { + "set-cookie": ["a=1", "b=2"], + "SET-COOKIE": 3 as unknown as string, + }), + ["a=1", "b=2", "3", ...cookies], + {}, + ], + [ + "an object without Set-Cookie", + (r): unknown => + r.setHeader("Set-Cookie", "a=1").writeHead(200, { "X-A": "1" }), + ["a=1", ...cookies], + { "x-a": "1" }, + ], + [ + "a reason phrase without headers", + (r): unknown => r.writeHead(200, "Fine"), + cookies, + {}, + ], + [ + "a flat array", + (r): unknown => + r + .setHeader("Set-Cookie", "old=0") + .writeHead(200, [ + "Set-Cookie", + "a=1", + "X-A", + "1", + "set-cookie", + "b=2", + ]), + ["a=1", "b=2", ...cookies], + { "x-a": "1" }, + ], + [ + "pairs", + (r): unknown => + r.writeHead(200, [ + ["X-A", "1"], + ["Set-Cookie", ["a=1"]], + ] as unknown as IHeadersArg), + ["a=1", ...cookies], + { "x-a": "1" }, + ], + [ + "an array without Set-Cookie", + (r): unknown => r.writeHead(200, ["X-A", "1"]), + cookies, + { "x-a": "1" }, + ], + [ + "the second argument when it is not a reason phrase", + (r): unknown => r.writeHead(200, none, { "X-A": "1" } as IHeadersArg), + cookies, + { "x-a": "1" }, + ], + ]; + for (const [name, writeHead, expected, extra] of cases) { + const handled = app.handle((_request, response) => { + response.cookies.add(cookie1).add(cookie2); + writeHead(response); + // Once: not again when the headers are sent + response.cookies.add(new Cookie({ key: "late", value: "1" })); + response.end(); + }); + const { headers } = await app.request(); + deepStrictEqual(await handled, null, name); + deepStrictEqual(headers["set-cookie"], expected, name); + for (const [key, value] of Object.entries(extra)) { + deepStrictEqual(headers[key], value, name); + } + } + }); + + test("cookies are added by the implicit headers", async () => { + const app = await start(); + const cases: [string, (response: BindenResponse) => unknown][] = [ + [ + "write()", + (r): void => { + r.write("body"); + r.end(); + }, + ], + ["end()", (r): unknown => r.end("body")], + [ + "flushHeaders()", + (r): void => { + r.flushHeaders(); + r.end(); + }, + ], + ["pipe()", (r): unknown => Readable.from(["a", "b"]).pipe(r)], + ["send()", (r): unknown => r.status(204).send()], + ]; + for (const [name, commit] of cases) { + const handled = app.handle((_request, response) => { + response.setHeader("Set-Cookie", "a=1"); + response.cookies.add(cookie1).add(cookie2); + return commit(response); + }); + const { headers } = await app.request(); + deepStrictEqual(await handled, null, name); + deepStrictEqual(headers["set-cookie"], ["a=1", ...cookies], name); + } + + // `HEAD` + const head = app.handle((_request, response) => { + response.cookies.add(cookie1); + response.end(); + }); + const { headers } = await app.request({ method: "HEAD" }); + deepStrictEqual(await head, null); + deepStrictEqual(headers["set-cookie"], [cookie1.toString()]); + }); + + test("cookies are kept for the next attempt when `writeHead()` throws", async () => { + const app = await start(); + const handled = app.handle((_request, response) => { + response.cookies.add(cookie1); + throws(() => response.writeHead(1000), { + code: "ERR_HTTP_INVALID_STATUS_CODE", + }); + ok(!response.hasHeader("Set-Cookie")); + response.setHeader("Set-Cookie", "a=1"); + throws(() => response.writeHead(200, { "X-Bad": "\r\n" }), { + code: "ERR_INVALID_CHAR", + }); + deepStrictEqual(response.getHeader("Set-Cookie"), "a=1"); + ok(!response.headersSent); + response.writeHead(200, { "Set-Cookie": "b=2" }); + throws(() => response.writeHead(200), { code: "ERR_HTTP_HEADERS_SENT" }); + response.end(); + }); + const { headers } = await app.request(); + deepStrictEqual(await handled, null); + deepStrictEqual(headers["set-cookie"], ["b=2", cookie1.toString()]); + }); +}); From 8cd4d8df434f2347b72eae7eb6598273deda41a2 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:25:14 +0300 Subject: [PATCH 126/675] feat: add flush() to the response in binden Streaming responses (e.g. server-sent events) need to know when what they have written has reached the connection. flush() sends the headers and resolves once the earlier writes are flushed, or rejects when the response is closed or fails before; the compression middleware will extend it to flush its compressor. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/response.ts | 31 ++++ packages/binden/test/response-commit.test.ts | 145 ++++++++++++++++++- 2 files changed, 175 insertions(+), 1 deletion(-) diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index 81dfe311..e5f3683e 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -428,6 +428,37 @@ export class BindenResponse< return this.setHeader("Content-Type", ct_form).send(params.toString()); } + /** + * Send the headers, then resolve once what has been written so far is + * flushed to the connection; rejects when the response is closed before + * (e.g. the client went away). `@binden/compression` replaces it to flush + * the compressor as well. + */ + public flush(): Promise { + if (this.writableFinished) { + return Promise.resolve(); + } else if (this.writableEnded || this.destroyed) { + return this.#finished(() => false); + } + return new Promise((resolve, reject) => { + const fail = (error?: Error): void => { + this.off("close", fail).off("error", fail); + reject(error ?? BindenResponse.#premature()); + }; + this.once("close", fail).once("error", fail); + this.flushHeaders(); + // Its callback runs once the writes before it are flushed + this.write("", (error?: Error | null) => { + this.off("close", fail).off("error", fail); + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } + /** A response without a body: to `HEAD`, or with a 1xx, 204, 205 or 304 status */ #bodiless(): boolean { return ( diff --git a/packages/binden/test/response-commit.test.ts b/packages/binden/test/response-commit.test.ts index 569892d5..0b37c697 100644 --- a/packages/binden/test/response-commit.test.ts +++ b/packages/binden/test/response-commit.test.ts @@ -1,11 +1,31 @@ import { deepStrictEqual, ok, throws } from "node:assert"; -import { type OutgoingHttpHeader, type OutgoingHttpHeaders } from "node:http"; +import { + IncomingMessage, + type OutgoingHttpHeader, + type OutgoingHttpHeaders, + get, + request, +} from "node:http"; +import { Socket } from "node:net"; import { Readable } from "node:stream"; import { afterEach, suite, test } from "node:test"; import { BindenResponse, Cookie } from "../index.js"; import { type ITestServer, type IListenOptions, listen } from "./http.js"; +/** A response without a connection: nothing is actually sent */ +function detached(): BindenResponse { + return new BindenResponse(new IncomingMessage(new Socket())); +} + +/** The settled value of a promise: `null` once resolved, or its error */ +function settle(promise: Promise): Promise { + return promise.then( + () => null, + (error: unknown) => error, + ); +} + /** `undefined`, which the linter does not let the tests write */ const none = ({} as { value?: undefined }).value; @@ -195,4 +215,127 @@ suite("BindenResponse (headers, flush, ETag and seal)", () => { deepStrictEqual(await handled, null); deepStrictEqual(headers["set-cookie"], ["b=2", cookie1.toString()]); }); + + test(".flush() sends the headers and what is written", async () => { + const app = await start(); + let release = (): void => { + // Replaced below + }; + const released = new Promise((resolve) => { + release = resolve; + }); + const handled = app.handle(async (_request, response) => { + response.cookies.add(cookie1); + await response.flush(); + ok(response.headersSent); + response.write("first"); + await response.flush(); + // The client has them before the end + await released; + response.end("last"); + }); + const received = await new Promise((resolve, reject) => { + const parts: string[] = []; + get(app.url, { signal: AbortSignal.timeout(5000) }, (response) => { + parts.push(String(response.headers["set-cookie"])); + response + .setEncoding("utf8") + .on("data", (chunk: string) => { + parts.push(chunk); + if (chunk === "first") { + release(); + } + }) + .once("end", () => { + resolve(parts); + }); + }).once("error", reject); + }); + deepStrictEqual(await handled, null); + deepStrictEqual(received, [cookie1.toString(), "first", "last"]); + + // Without a body (`HEAD`), then ended and finished + let headers = (): void => { + // Replaced below + }; + const sent = new Promise((resolve) => { + headers = resolve; + }); + const head = app.handle(async (_request, response) => { + await response.flush(); + // The client has them before the end + await sent; + response.end(); + const finishing = response.flush(); + await finishing; + ok(response.writableFinished); + await response.flush(); + }); + await new Promise((resolve, reject) => { + request( + app.url, + { method: "HEAD", signal: AbortSignal.timeout(5000) }, + (response) => { + headers(); + response.resume().once("end", resolve); + }, + ) + .once("error", reject) + .end(); + }); + deepStrictEqual(await head, null); + }); + + test(".flush() rejects when the response is closed or fails", async () => { + const closed = detached(); + const flushing = closed.flush(); + // Committed, but not written without a connection + ok(closed.headersSent); + closed.emit("close"); + deepStrictEqual( + ((await settle(flushing)) as { code?: unknown }).code, + "ERR_STREAM_PREMATURE_CLOSE", + ); + deepStrictEqual(closed.listenerCount("close"), 0); + + const failing = new Error("failed"); + const errored = detached(); + const erroring = errored.flush(); + errored.emit("error", failing); + deepStrictEqual(await settle(erroring), failing); + deepStrictEqual(errored.listenerCount("error"), 0); + + const unknown = detached(); + const unknownFlush = unknown.flush(); + unknown.emit("error"); + deepStrictEqual( + ((await settle(unknownFlush)) as { code?: unknown }).code, + "ERR_STREAM_PREMATURE_CLOSE", + ); + + // A write that fails + const write = detached(); + write.write = ((_chunk: unknown, callback: (error?: Error) => void) => { + callback(failing); + return false; + }) as typeof write.write; + deepStrictEqual(await settle(write.flush()), failing); + deepStrictEqual(write.listenerCount("close"), 0); + + // Destroyed, or ended and then closed before it has finished + const destroyed = detached(); + destroyed.destroy(); + deepStrictEqual( + ((await settle(destroyed.flush())) as { code?: unknown }).code, + "ERR_STREAM_PREMATURE_CLOSE", + ); + const ended = detached(); + ended.end(); + const endedFlush = ended.flush(); + ended.emit("close"); + deepStrictEqual( + ((await settle(endedFlush)) as { code?: unknown }).code, + "ERR_STREAM_PREMATURE_CLOSE", + ); + }); }); From 75d40fec865bc33166eebaf94fe878c8c50be1d6 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:25:49 +0300 Subject: [PATCH 127/675] feat!: serve files from a root with the options of sendFile() in binden sendFile() trusted its path, so serving a path from a request needed checks that applications got wrong. With `root`, the path must be a relative filesystem path whose segments are not empty, `.`, `..`, NUL (nor a drive, stream or UNC path on Windows), and whose real path stays inside the real path of the root (by path.relative(), not a prefix), else 404; dotfiles in the path or its real path are ignored (404), denied (403) or allowed. The file is opened with O_NOFOLLOW. It now also sends a weak stat ETag, a Content-Type from a table of common web types and X-Content-Type-Options, with options for the validator, Last-Modified, Cache-Control and the type. The logic moves to send-file.ts and mime.ts. BREAKING CHANGE: `sendFile()` sends a weak `ETag` (`W/"-"`), a `Content-Type` from the extension of the file and `X-Content-Type-Options: nosniff` by default; pass `{ etag: false }` or `{ content_type: false }` to keep the previous headers. Co-Authored-By: Claude Opus 5.5 --- packages/binden/index.ts | 1 + packages/binden/src/mime.ts | 65 +++ packages/binden/src/response.ts | 228 +-------- packages/binden/src/send-file.ts | 550 ++++++++++++++++++++ packages/binden/test/send-file.test.ts | 671 +++++++++++++++++++++++++ 5 files changed, 1297 insertions(+), 218 deletions(-) create mode 100644 packages/binden/src/mime.ts create mode 100644 packages/binden/src/send-file.ts create mode 100644 packages/binden/test/send-file.test.ts diff --git a/packages/binden/index.ts b/packages/binden/index.ts index 4384a99f..c1f0cd60 100644 --- a/packages/binden/index.ts +++ b/packages/binden/index.ts @@ -17,5 +17,6 @@ export * from "./src/binden.js"; export * from "./src/middleware.js"; export * from "./src/request.js"; export * from "./src/response.js"; +export type { ISendFileOptions } from "./src/send-file.js"; export * from "./src/router.js"; export { serializers } from "./src/logger.js"; diff --git a/packages/binden/src/mime.ts b/packages/binden/src/mime.ts new file mode 100644 index 00000000..bf03e009 --- /dev/null +++ b/packages/binden/src/mime.ts @@ -0,0 +1,65 @@ +import { extname } from "node:path"; + +/** The media type of the files that the table does not know */ +export const ct_octet_stream = "application/octet-stream"; + +const utf8 = "; charset=utf-8"; + +/** + * The media types of common web files, by their extension in lowercase (text + * types are sent as UTF-8) + */ +const types: ReadonlyMap = new Map([ + ["avif", "image/avif"], + ["bmp", "image/bmp"], + ["cjs", `text/javascript${utf8}`], + ["css", `text/css${utf8}`], + ["csv", `text/csv${utf8}`], + ["gif", "image/gif"], + ["gz", "application/gzip"], + ["htm", `text/html${utf8}`], + ["html", `text/html${utf8}`], + ["ico", "image/vnd.microsoft.icon"], + ["ics", `text/calendar${utf8}`], + ["jpeg", "image/jpeg"], + ["jpg", "image/jpeg"], + ["js", `text/javascript${utf8}`], + ["json", "application/json"], + ["jsonld", "application/ld+json"], + ["m4a", "audio/mp4"], + ["map", "application/json"], + ["md", `text/markdown${utf8}`], + ["mjs", `text/javascript${utf8}`], + ["mp3", "audio/mpeg"], + ["mp4", "video/mp4"], + ["oga", "audio/ogg"], + ["ogg", "audio/ogg"], + ["ogv", "video/ogg"], + ["otf", "font/otf"], + ["pdf", "application/pdf"], + ["png", "image/png"], + ["svg", "image/svg+xml"], + ["tar", "application/x-tar"], + ["ttf", "font/ttf"], + ["txt", `text/plain${utf8}`], + ["wasm", "application/wasm"], + ["wav", "audio/wav"], + ["weba", "audio/webm"], + ["webm", "video/webm"], + ["webmanifest", "application/manifest+json"], + ["webp", "image/webp"], + ["woff", "font/woff"], + ["woff2", "font/woff2"], + ["xml", "application/xml"], + ["yaml", `application/yaml`], + ["yml", `application/yaml`], + ["zip", "application/zip"], +]); + +/** + * The `Content-Type` of a file by the extension of its name, from a table of + * common web types (`application/octet-stream` for any other file) + */ +export function contentTypeOf(name: string): string { + return types.get(extname(name).slice(1).toLowerCase()) ?? ct_octet_stream; +} diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index e5f3683e..c947dabf 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -1,22 +1,13 @@ -import { constants } from "node:fs"; -import { open } from "node:fs/promises"; import { - type IncomingHttpHeaders, IncomingMessage, type OutgoingHttpHeader, type OutgoingHttpHeaders, ServerResponse, } from "node:http"; import { Readable } from "node:stream"; -import { pathToFileURL } from "node:url"; -import ContentRange from "./headers/content-range.js"; import ContentType from "./headers/content-type.js"; import Cookie, { type ICookie } from "./headers/cookie.js"; -import IfModifiedSince from "./headers/if-modified-since.js"; -import { parseHttpDate } from "./headers/http-date.js"; -import { split } from "./headers/list.js"; -import Range from "./headers/range.js"; -import { BindenRequest } from "./request.js"; +import { type ISendFileOptions, sendFileTo } from "./send-file.js"; import { isStatus } from "./status.js"; export const ct_json = "application/json"; @@ -673,221 +664,22 @@ export class BindenResponse< } /** - * Sends a file. Its metadata and its content are read from one file handle, - * so that they belong to the same file (also when the path is replaced), and - * no more than its size is sent. + * Send a file: a filesystem path (or a `file:` URL), trusted without `root`; + * with `root`, a relative path that must stay inside it (see + * `ISendFileOptions`). Its metadata and its content are read from one file + * handle, and no more than its size is sent. */ - public async sendFile(path: URL | string): Promise { + public async sendFile( + path: URL | string, + options?: ISendFileOptions, + ): Promise { // Started at once (see `.started`), unless it fails before sending anything this.#started = true; try { - await this.#sendFile(path); + await sendFileTo(this, path, options); } catch (error: unknown) { this.#started = false; throw error; } } - - async #sendFile(path: URL | string): Promise { - const url = path instanceof URL ? new URL(path.href) : pathToFileURL(path); - - if (url.protocol !== "file:") { - throw new TypeError(`Protocol ${url.protocol} is not supported`); - } - - // Without waiting for a writer of a FIFO (`O_NONBLOCK` is not defined on - // Windows, which has no FIFOs) - const handle = await open(url, constants.O_RDONLY | constants.O_NONBLOCK); - // Closed by the stream of its content, or else here - let stream: Readable | null = null; - try { - const stats = await handle.stat(); - - if (!stats.isFile()) { - throw new Error("Provided path does not correspond to a regular file"); - } - - const { mtime, size } = stats; - // In seconds, as an HTTP-date, and not in the future (RFC 9110, section 8.8.2.1) - const lm = new Date( - Math.floor(Math.min(mtime.getTime(), Date.now()) / 1000) * 1000, - ); - - this.set({ "Last-Modified": lm.toUTCString(), "Accept-Ranges": "bytes" }); - - const { method = "GET", headers } = this.req; - const ims = - this.req instanceof BindenRequest - ? this.req.if_modified_since - : IfModifiedSince.fromString(headers["if-modified-since"]); - - const failed = BindenResponse.#preconditions( - headers, - this.getHeader("ETag"), - lm, - method === "GET" || method === "HEAD", - ims, - ); - if (failed !== null) { - await this.status(failed).send(); - return; - } else if (method === "HEAD") { - // The headers of the whole file, without reading it - await this.setHeader("Content-Length", size).send(); - return; - } - - // Ranges apply to GET only (RFC 9110, section 14.2) - const ranges = - method !== "GET" || !this.#ifRange(lm) - ? [] - : this.req instanceof BindenRequest - ? this.req.range - : Range.fromString(headers.range); - const range = BindenResponse.#range(ranges, size); - - if (range === "unsatisfiable") { - const cr = new ContentRange({ size }).toString(); - await this.setHeader("Content-Range", cr).status(416).send(); - return; - } - - if (range === null && size === 0) { - await this.setHeader("Content-Length", 0).send(); - return; - } - - // No more than the size that is sent as its length - const { start, end } = range ?? { start: 0, end: size - 1 }; - stream = handle.createReadStream({ start, end }); - if (range === null) { - await this.setHeader("Content-Length", size).send(stream); - return; - } - const cr = new ContentRange({ size, start, end }).toString(); - await this.set({ "Content-Length": end - start + 1, "Content-Range": cr }) - .status(206) - .send(stream); - } finally { - if (stream === null) { - await handle.close(); - } - } - } - - /** - * The status of a request whose precondition fails, in the order of RFC 9110 - * (section 13.2.2), or `null` when they pass: `If-Match` (or else - * `If-Unmodified-Since`), then `If-None-Match` (or else `If-Modified-Since`, - * for `GET` and `HEAD`), with the `ETag` set on the response - */ - static #preconditions( - headers: IncomingHttpHeaders, - etag: unknown, - lm: Date, - read: boolean, - ims: IfModifiedSince | null, - ): 304 | 412 | null { - const tag = typeof etag === "string" ? etag : null; - const ifMatch = headers["if-match"]; - const ius = parseHttpDate(headers["if-unmodified-since"]?.trim() ?? ""); - const ifNoneMatch = headers["if-none-match"]; - if (typeof ifMatch === "string") { - if (!BindenResponse.#matches(ifMatch, tag, true)) { - return 412; - } - } else if (ius !== null && lm > ius) { - return 412; - } - if (typeof ifNoneMatch === "string") { - if (BindenResponse.#matches(ifNoneMatch, tag, false)) { - return read ? 304 : 412; - } - } else if (read && ims !== null && lm <= ims.date) { - return 304; - } - return null; - } - - /** - * Whether a list of entity-tags (or `*`, as the file exists) matches the - * `ETag` of the response, by the strong or the weak comparison (RFC 9110, - * section 8.8.3.2) - */ - static #matches(list: string, etag: string | null, strong: boolean): boolean { - if (list.trim() === "*") { - return true; - } else if (etag === null || (strong && etag.startsWith("W/"))) { - return false; - } - const opaque = etag.replace(/^W\//u, ""); - return split(list, ",").some((value) => { - const tag = value.trim(); - return strong ? tag === etag : tag.replace(/^W\//u, "") === opaque; - }); - } - - /** Whether `If-Range`, when sent, matches the file (RFC 9110, section 13.1.5) */ - #ifRange(lm: Date): boolean { - const raw = this.req.headers["if-range"]; - // Sent more than once, it is invalid: it does not match - const value = typeof raw === "string" ? raw.trim() : ""; - if (typeof raw === "undefined") { - return true; - } else if (value.startsWith(`"`) || value.startsWith("W/")) { - // An entity-tag matches a strong `ETag` of the response only - const etag = this.getHeader("ETag"); - return etag === value && !value.startsWith("W/"); - } - // An HTTP-date matches the `Last-Modified` exactly, when it is a strong - // validator: at least a second before now (RFC 9110, section 8.8.2.2) - const now = Math.floor(Date.now() / 1000) * 1000; - return ( - parseHttpDate(value)?.getTime() === lm.getTime() && - now - lm.getTime() >= 1000 - ); - } - - /** - * The byte range of a file to send: `null` for the whole file (without a - * range, or with several distinct ones: `multipart/byteranges` is not - * supported), or `"unsatisfiable"` (RFC 9110, section 14.1.1) - */ - static #range( - ranges: readonly Range[], - size: number, - ): { start: number; end: number } | "unsatisfiable" | null { - if (ranges.length === 0) { - return null; - } - - const satisfiable = ranges - .flatMap(({ start, end }): [number, number][] => { - if (start === null) { - // A suffix: the last `end` bytes - return end !== null && end > 0 && size > 0 - ? [[Math.max(0, size - end), size - 1]] - : []; - } - return start < size ? [[start, Math.min(end ?? size, size - 1)]] : []; - }) - .sort(([a], [b]) => a - b); - const [first, ...rest] = satisfiable; - - if (typeof first === "undefined") { - return "unsatisfiable"; - } - - // Overlapping or adjacent ranges are one range - const [start] = first; - let [, end] = first; - for (const [nextStart, nextEnd] of rest) { - if (nextStart > end + 1) { - return null; - } - end = Math.max(end, nextEnd); - } - - return start === 0 && end === size - 1 ? null : { start, end }; - } } diff --git a/packages/binden/src/send-file.ts b/packages/binden/src/send-file.ts new file mode 100644 index 00000000..14900586 --- /dev/null +++ b/packages/binden/src/send-file.ts @@ -0,0 +1,550 @@ +import { constants } from "node:fs"; +import { type FileHandle, open, realpath } from "node:fs/promises"; +import type { IncomingHttpHeaders } from "node:http"; +import native, { type PlatformPath, join } from "node:path"; +import type { Readable } from "node:stream"; +import { fileURLToPath } from "node:url"; + +import { BindenError } from "./error.js"; +import ContentRange from "./headers/content-range.js"; +import ContentType from "./headers/content-type.js"; +import IfModifiedSince from "./headers/if-modified-since.js"; +import { parseHttpDate } from "./headers/http-date.js"; +import { split } from "./headers/list.js"; +import Range from "./headers/range.js"; +import { contentTypeOf } from "./mime.js"; +import { BindenRequest } from "./request.js"; +import type { BindenResponse } from "./response.js"; + +/** The options of `BindenResponse.sendFile()` */ +export interface ISendFileOptions { + /** + * The directory to serve the file from: the path must then be a relative + * string (a filesystem path, not URL-encoded) that stays inside it + */ + readonly root?: URL | string | undefined; + /** + * The files and directories whose name starts with a dot, under `root`: + * `"ignore"` (404, the default), `"deny"` (403) or `"allow"` + */ + readonly dotfiles?: "allow" | "deny" | "ignore" | undefined; + /** A weak `ETag` from the size and the modification time (`true` by default) */ + readonly etag?: boolean | undefined; + /** A strong `ETag` (e.g. `""`) that the application guarantees for the file */ + readonly validator?: string | undefined; + /** The `Last-Modified` header (`true` by default) */ + readonly last_modified?: boolean | undefined; + /** The `Cache-Control` header (not with `max_age` or `immutable`) */ + readonly cache_control?: string | undefined; + /** `Cache-Control: public, max-age=` */ + readonly max_age?: number | undefined; + /** Adds `immutable` to the `Cache-Control` of `max_age` */ + readonly immutable?: boolean | undefined; + /** + * The `Content-Type`: by default, from the extension of the file (or + * `application/octet-stream`) unless the response has one; `false` leaves it + */ + readonly content_type?: false | string | undefined; +} + +/** The options of `sendFile()`, validated */ +interface IFileOptions { + readonly root: string | null; + readonly dotfiles: "allow" | "deny" | "ignore"; + readonly etag: boolean; + readonly validator: string | null; + readonly last_modified: boolean; + readonly cache_control: string | null; + readonly content_type: false | string | null; +} + +/** The file to open: its path (or URL), its name, and whether it is under `root` */ +interface ILocation { + readonly file: URL | string; + readonly name: string; + readonly contained: boolean; +} + +const policies: ReadonlySet = new Set(["allow", "deny", "ignore"]); + +/** A strong entity-tag (RFC 9110, section 8.8.3) */ +const strongTag = /^"[\x21\x23-\x7E]*"$/u; + +/** A field value of visible ASCII characters, spaces and tabs */ +const fieldValue = /^[\t\x20-\x7E]+$/u; + +/** The errors of a path that does not lead to a file */ +const missing: ReadonlySet = new Set([ + "EISDIR", + "ELOOP", + "ENAMETOOLONG", + "ENOENT", + "ENOTDIR", +]); + +const windows = process.platform === "win32"; + +/** Throws a `TypeError` unless the option is a boolean (or not set) */ +function boolean(value: unknown, name: string): void { + if (typeof value !== "undefined" && typeof value !== "boolean") { + throw new TypeError(`\`${name}\` is not a boolean`); + } +} + +/** The absolute path of `root` (a non-empty path, or a `file:` URL) */ +function parseRoot(root: unknown): string | null { + if (typeof root === "undefined") { + return null; + } else if (root instanceof URL) { + if (root.protocol !== "file:") { + throw new TypeError(`Protocol ${root.protocol} is not supported`); + } + return fileURLToPath(root); + } else if (typeof root !== "string" || root === "") { + throw new TypeError("`root` is not a non-empty string or a URL"); + } + return root; +} + +/** Validates the options (a `TypeError` or a `RangeError` for invalid ones) */ +function parseOptions({ + root, + dotfiles = "ignore", + etag = true, + validator, + last_modified = true, + cache_control, + max_age, + immutable, + content_type, +}: ISendFileOptions): IFileOptions { + boolean(etag, "etag"); + boolean(last_modified, "last_modified"); + boolean(immutable, "immutable"); + if (!policies.has(dotfiles)) { + throw new TypeError("`dotfiles` is not one of allow, deny and ignore"); + } else if (typeof validator !== "undefined" && !strongTag.test(validator)) { + throw new TypeError("`validator` is not a strong entity-tag"); + } else if ( + typeof cache_control !== "undefined" && + (typeof max_age !== "undefined" || typeof immutable !== "undefined") + ) { + throw new TypeError("`cache_control` excludes `max_age` and `immutable`"); + } else if ( + typeof cache_control !== "undefined" && + !fieldValue.test(cache_control) + ) { + throw new TypeError("`cache_control` is not a valid field value"); + } else if (typeof max_age !== "undefined" && !Number.isSafeInteger(max_age)) { + throw new TypeError("`max_age` is not an integer"); + } else if (typeof max_age !== "undefined" && max_age < 0) { + throw new RangeError("`max_age` is negative"); + } else if ( + typeof immutable !== "undefined" && + typeof max_age === "undefined" + ) { + throw new TypeError("`immutable` needs `max_age`"); + } + + let type: false | string | null = null; + if (typeof content_type === "string") { + const parsed = ContentType.fromString(content_type); + if (parsed === null) { + throw new TypeError(`Invalid media type: ${content_type}`); + } + type = parsed.toString(); + } else if (content_type === false) { + type = false; + } else if (typeof content_type !== "undefined") { + throw new TypeError("`content_type` is not a string or `false`"); + } + + return { + root: parseRoot(root), + dotfiles, + etag, + validator: validator ?? null, + last_modified, + cache_control: + cache_control ?? + (typeof max_age === "undefined" + ? null + : `public, max-age=${max_age}${immutable === true ? ", immutable" : ""}`), + content_type: type, + }; +} + +/** + * The segments of a path relative to `root`, split on `/` (and `\` on + * Windows), or `null` when it is not one: an empty segment (also of an + * absolute or UNC path), `.` or `..`, a NUL character, or (on Windows) a + * drive letter or a stream (`:`) + */ +export function relativeSegments(path: string, win: boolean): string[] | null { + const segments = path.split(win ? /[/\\]/u : "/"); + const valid = segments.every( + (segment) => + segment !== "" && + segment !== "." && + segment !== ".." && + !segment.includes("\0") && + !(win && segment.includes(":")), + ); + return valid ? segments : null; +} + +/** Applies the `dotfiles` policy to the segments of a path */ +function checkDotfiles( + segments: readonly string[], + dotfiles: IFileOptions["dotfiles"], +): void { + if (dotfiles !== "allow" && segments.some((s) => s.startsWith("."))) { + throw new BindenError(dotfiles === "deny" ? 403 : 404); + } +} + +/** A 404 error for a path that does not lead to a file, the error itself otherwise */ +function notFound(error: unknown): unknown { + return missing.has((error as { code?: unknown }).code) + ? new BindenError(404, { cause: error }) + : error; +} + +/** + * The segments of the path of `file` relative to the directory `base` (both + * real paths), or `null` when it is not inside it: by `path.relative()`, not + * by a prefix (`/srv/public-old` is not inside `/srv/public`), nor on another + * drive (Windows) + */ +export function within( + base: string, + file: string, + platform: PlatformPath = native, +): string[] | null { + const inside = platform.relative(base, file); + const parts = inside.split(platform.sep); + return inside === "" || parts[0] === ".." || platform.isAbsolute(inside) + ? null + : parts; +} + +/** + * The file of `path`: as it is without `root` (a trusted path, or a `file:` + * URL), otherwise the real path of a relative path that stays inside the real + * path of `root` (404 outside of it, or for a symbolic link that leads out) + */ +async function locate( + path: URL | string, + { root, dotfiles }: IFileOptions, +): Promise { + if (root === null) { + if (path instanceof URL) { + if (path.protocol !== "file:") { + throw new TypeError(`Protocol ${path.protocol} is not supported`); + } + return { + file: new URL(path.href), + name: path.pathname, + contained: false, + }; + } else if (typeof path !== "string") { + throw new TypeError("The path is not a string or a URL"); + } + return { file: path, name: path, contained: false }; + } else if (typeof path !== "string") { + throw new TypeError("With `root`, the path must be a relative string"); + } + + const segments = relativeSegments(path, windows); + if (segments === null) { + throw new BindenError(404); + } + checkDotfiles(segments, dotfiles); + + const base = await realpath(root); + const file = await realpath(join(base, ...segments)).catch( + (error: unknown) => { + throw notFound(error); + }, + ); + const parts = within(base, file); + if (parts === null) { + throw new BindenError(404); + } + // Also the real names (e.g. of a symbolic link to a dotfile) + checkDotfiles(parts, dotfiles); + return { file, name: path, contained: true }; +} + +/** + * Opens a file without waiting for a writer of a FIFO (`O_NONBLOCK` is not + * defined on Windows, which has no FIFOs) and, under `root`, without following + * a symbolic link that replaced the file since its real path was resolved + */ +async function openFile({ file, contained }: ILocation): Promise { + const flags = + constants.O_RDONLY | + constants.O_NONBLOCK | + (contained ? constants.O_NOFOLLOW : 0); + try { + return await open(file, flags); + } catch (error: unknown) { + throw contained ? notFound(error) : error; + } +} + +/** + * Whether a list of entity-tags (or `*`, as the representation exists) matches + * an `ETag`, by the strong or the weak comparison (RFC 9110, section 8.8.3.2) + */ +export function matches( + list: string, + etag: string | null, + strong: boolean, +): boolean { + if (list.trim() === "*") { + return true; + } else if (etag === null || (strong && etag.startsWith("W/"))) { + return false; + } + const opaque = etag.replace(/^W\//u, ""); + return split(list, ",").some((value) => { + const tag = value.trim(); + return strong ? tag === etag : tag.replace(/^W\//u, "") === opaque; + }); +} + +/** + * The status of a request whose precondition fails, in the order of RFC 9110 + * (section 13.2.2), or `null` when they pass: `If-Match` (or else + * `If-Unmodified-Since`), then `If-None-Match` (or else `If-Modified-Since`, + * for `GET` and `HEAD`), with the `ETag` set on the response. The dates are + * ignored without a `Last-Modified` (`lm`). + */ +function preconditions( + headers: IncomingHttpHeaders, + etag: unknown, + lm: Date | null, + read: boolean, + ims: IfModifiedSince | null, +): 304 | 412 | null { + const tag = typeof etag === "string" ? etag : null; + const ifMatch = headers["if-match"]; + const ius = parseHttpDate(headers["if-unmodified-since"]?.trim() ?? ""); + const ifNoneMatch = headers["if-none-match"]; + if (typeof ifMatch === "string") { + if (!matches(ifMatch, tag, true)) { + return 412; + } + } else if (lm !== null && ius !== null && lm > ius) { + return 412; + } + if (typeof ifNoneMatch === "string") { + if (matches(ifNoneMatch, tag, false)) { + return read ? 304 : 412; + } + } else if (read && lm !== null && ims !== null && lm <= ims.date) { + return 304; + } + return null; +} + +/** + * Whether `If-Range`, when sent, matches the file (RFC 9110, section 13.1.5): + * its strong `ETag`, or exactly its `Last-Modified` when that is a strong + * validator + */ +function ifRange( + raw: string[] | string | undefined, + etag: unknown, + lm: Date | null, +): boolean { + // Sent more than once, it is invalid: it does not match + const value = typeof raw === "string" ? raw.trim() : ""; + if (typeof raw === "undefined") { + return true; + } else if (value.startsWith(`"`) || value.startsWith("W/")) { + // An entity-tag matches a strong `ETag` of the response only + return etag === value && !value.startsWith("W/"); + } + // An HTTP-date matches the `Last-Modified` exactly, when it is a strong + // validator: at least a second before now (RFC 9110, section 8.8.2.2) + const now = Math.floor(Date.now() / 1000) * 1000; + return ( + lm !== null && + parseHttpDate(value)?.getTime() === lm.getTime() && + now - lm.getTime() >= 1000 + ); +} + +/** + * The byte range of a file to send: `null` for the whole file (without a + * range, or with several distinct ones: `multipart/byteranges` is not + * supported), or `"unsatisfiable"` (RFC 9110, section 14.1.1) + */ +function byteRange( + ranges: readonly Range[], + size: number, +): { start: number; end: number } | "unsatisfiable" | null { + if (ranges.length === 0) { + return null; + } + + const satisfiable = ranges + .flatMap(({ start, end }): [number, number][] => { + if (start === null) { + // A suffix: the last `end` bytes + return end !== null && end > 0 && size > 0 + ? [[Math.max(0, size - end), size - 1]] + : []; + } + return start < size ? [[start, Math.min(end ?? size, size - 1)]] : []; + }) + .sort(([a], [b]) => a - b); + const [first, ...rest] = satisfiable; + + if (typeof first === "undefined") { + return "unsatisfiable"; + } + + // Overlapping or adjacent ranges are one range + const [start] = first; + let [, end] = first; + for (const [nextStart, nextEnd] of rest) { + if (nextStart > end + 1) { + return null; + } + end = Math.max(end, nextEnd); + } + + return start === 0 && end === size - 1 ? null : { start, end }; +} + +/** The validators, caching and representation headers of a file */ +function setHeaders( + response: BindenResponse, + options: IFileOptions, + name: string, + lm: Date | null, + { mtimeMs, size }: { mtimeMs: number; size: number }, +): void { + const { validator, etag, cache_control, content_type } = options; + if (validator !== null) { + response.setHeader("ETag", validator); + } else if (etag && !response.hasHeader("ETag")) { + const weak = `W/"${size.toString(16)}-${Math.floor(mtimeMs).toString(16)}"`; + response.setHeader("ETag", weak); + } + if (lm !== null) { + response.setHeader("Last-Modified", lm.toUTCString()); + } + if (cache_control !== null) { + response.setHeader("Cache-Control", cache_control); + } + if (typeof content_type === "string") { + response.setHeader("Content-Type", content_type); + } else if (content_type === null && !response.hasHeader("Content-Type")) { + response.setHeader("Content-Type", contentTypeOf(name)); + } + response.set({ + "Accept-Ranges": "bytes", + "X-Content-Type-Options": "nosniff", + }); +} + +/** + * Sends a file (see `BindenResponse.sendFile()`). Its metadata and its content + * are read from one file handle, so that they belong to the same file (also + * when the path is replaced), and no more than its size is sent. + */ +export async function sendFileTo( + response: BindenResponse, + path: URL | string, + options: ISendFileOptions = {}, +): Promise { + const parsed = parseOptions(options); + const location = await locate(path, parsed); + const handle = await openFile(location); + // Closed by the stream of its content, or else here + let stream: Readable | null = null; + try { + const stats = await handle.stat(); + + if (!stats.isFile()) { + throw location.contained + ? new BindenError(404) + : new Error("Provided path does not correspond to a regular file"); + } + + const { mtime, size } = stats; + // In seconds, as an HTTP-date, and not in the future (RFC 9110, section 8.8.2.1) + const lm = parsed.last_modified + ? new Date( + Math.floor(Math.min(mtime.getTime(), Date.now()) / 1000) * 1000, + ) + : null; + + setHeaders(response, parsed, location.name, lm, stats); + + const { req } = response; + const { method = "GET", headers } = req; + const ims = + req instanceof BindenRequest + ? req.if_modified_since + : IfModifiedSince.fromString(headers["if-modified-since"]); + + const failed = preconditions( + headers, + response.getHeader("ETag"), + lm, + method === "GET" || method === "HEAD", + ims, + ); + if (failed !== null) { + await response.status(failed).send(); + return; + } else if (method === "HEAD") { + // The headers of the whole file, without reading it + await response.setHeader("Content-Length", size).send(); + return; + } + + // Ranges apply to GET only (RFC 9110, section 14.2) + const ranges = + method !== "GET" || + !ifRange(headers["if-range"], response.getHeader("ETag"), lm) + ? [] + : req instanceof BindenRequest + ? req.range + : Range.fromString(headers.range); + const range = byteRange(ranges, size); + + if (range === "unsatisfiable") { + const cr = new ContentRange({ size }).toString(); + await response.setHeader("Content-Range", cr).status(416).send(); + return; + } + + if (range === null && size === 0) { + await response.setHeader("Content-Length", 0).send(); + return; + } + + // No more than the size that is sent as its length + const { start, end } = range ?? { start: 0, end: size - 1 }; + stream = handle.createReadStream({ start, end }); + if (range === null) { + await response.setHeader("Content-Length", size).send(stream); + return; + } + const cr = new ContentRange({ size, start, end }).toString(); + await response + .set({ "Content-Length": end - start + 1, "Content-Range": cr }) + .status(206) + .send(stream); + } finally { + if (stream === null) { + await handle.close(); + } + } +} diff --git a/packages/binden/test/send-file.test.ts b/packages/binden/test/send-file.test.ts new file mode 100644 index 00000000..224b7939 --- /dev/null +++ b/packages/binden/test/send-file.test.ts @@ -0,0 +1,671 @@ +/* eslint-disable init-declarations */ +import { deepStrictEqual, ok } from "node:assert"; +import fsp, { + mkdir, + mkdtemp, + realpath, + rm, + stat, + symlink, + unlink, + utimes, + writeFile, +} from "node:fs/promises"; +import { syncBuiltinESMExports } from "node:module"; +import { tmpdir } from "node:os"; +import { join, posix, relative, win32 } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + after, + afterEach, + before, + beforeEach, + mock, + suite, + test, +} from "node:test"; + +import { BindenError, type ISendFileOptions } from "../index.js"; +import { contentTypeOf } from "../src/mime.js"; +import { relativeSegments, within } from "../src/send-file.js"; +import { type IReply, type ITestServer, listen } from "./http.js"; + +interface IServed extends IReply { + /** `null` once `sendFile()` has resolved, otherwise its error */ + readonly error: unknown; +} + +const posixOnly = { skip: process.platform === "win32" }; +const windowsOnly = { skip: process.platform !== "win32" }; + +suite("sendFile", () => { + // `tmp/public` is the root, next to files and directories outside of it + let tmp: string; + let root: string; + let server: ITestServer; + const html = "

index

"; + + before(async () => { + tmp = await realpath(await mkdtemp(join(tmpdir(), "binden-send-file-"))); + root = join(tmp, "public"); + await mkdir(join(root, "dir"), { recursive: true }); + await mkdir(join(root, ".hidden")); + await mkdir(join(tmp, "private")); + await mkdir(join(tmp, "public-old")); + const files: [string, string][] = [ + ["public/index.html", html], + ["public/style.CSS", "p {}"], + ["public/data.unknown", "data"], + ["public/noext", "noext"], + ["public/a%20b", "encoded"], + ["public/.secret", "secret"], + ["public/.hidden/file.txt", "hidden"], + ["public/dir/nested.txt", "nested"], + ["outside.txt", "outside"], + ["private/secret.txt", "private"], + ["public-old/file.txt", "old"], + ]; + for (const [path, content] of files) { + await writeFile(join(tmp, path), content); + } + const links: [string, string][] = [ + ["index.html", "public/link-inside"], + ["../outside.txt", "public/link-outside"], + ["../private", "public/link-dir-outside"], + ["../public-old/file.txt", "public/link-old"], + [".secret", "public/link-dot"], + [".", "public/self"], + ["loop2", "public/loop1"], + ["loop1", "public/loop2"], + ]; + for (const [target, path] of links) { + await symlink(target, join(tmp, path)); + } + }); + + after(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + beforeEach(async () => { + server = await listen(); + }); + + afterEach(async () => { + mock.restoreAll(); + syncBuiltinESMExports(); + await server.close(); + }); + + /** Requests a file of `sendFile(path, options)`: an error is replied with its status */ + const serve = async ( + path: URL | string, + options?: ISendFileOptions, + request: Parameters[0] = {}, + ): Promise => { + let error: unknown = null; + const handled = server.handle(async (_request, response) => { + try { + await response.sendFile(path, options); + } catch (reason: unknown) { + error = reason; + ok(!response.started); + const status = reason instanceof BindenError ? reason.status : 500; + await response.status(status).send(); + } + }); + const reply = await server.request(request); + deepStrictEqual(await handled, null); + return { ...reply, error }; + }; + + /** The status of `sendFile(path, { root, ...options })` */ + const status = async ( + path: URL | string, + options: ISendFileOptions = {}, + ): Promise => (await serve(path, { root, ...options })).status; + + test("relativeSegments()", () => { + const cases: [string, string[] | null, string[] | null][] = [ + // [path, POSIX, Windows] + ["a/b.txt", ["a", "b.txt"], ["a", "b.txt"]], + ["a\\b.txt", ["a\\b.txt"], ["a", "b.txt"]], + [".a/b", [".a", "b"], [".a", "b"]], + ["a:b", ["a:b"], null], + ["C:x", ["C:x"], null], + ["/a", null, null], + ["\\a", ["\\a"], null], + ["C:\\a", ["C:\\a"], null], + ["C:/a", ["C:", "a"], null], + ["\\\\server\\share\\a", ["\\\\server\\share\\a"], null], + ["//server/share/a", null, null], + ["", null, null], + ["a//b", null, null], + ["a/", null, null], + ["a\\\\b", ["a\\\\b"], null], + [".", null, null], + ["./a", null, null], + ["a/../b", null, null], + ["..", null, null], + ["a\\..\\b", ["a\\..\\b"], null], + ["a\0b", null, null], + ["..a/b..", ["..a", "b.."], ["..a", "b.."]], + ]; + for (const [path, onPosix, onWindows] of cases) { + deepStrictEqual(relativeSegments(path, false), onPosix, `POSIX ${path}`); + deepStrictEqual( + relativeSegments(path, true), + onWindows, + `Windows ${path}`, + ); + } + }); + + test("within()", () => { + deepStrictEqual(within("/srv/public", "/srv/public/a/b", posix), [ + "a", + "b", + ]); + deepStrictEqual(within("/srv/public", "/srv/public/..a", posix), ["..a"]); + // Not by a prefix + deepStrictEqual(within("/srv/public", "/srv/public-old/a", posix), null); + deepStrictEqual(within("/srv/public", "/srv/public", posix), null); + deepStrictEqual(within("/srv/public", "/srv", posix), null); + deepStrictEqual(within("/srv/public", "/etc/passwd", posix), null); + deepStrictEqual(within("C:\\root", "C:\\root\\a\\b", win32), ["a", "b"]); + // Another drive + deepStrictEqual(within("C:\\root", "D:\\root\\a", win32), null); + deepStrictEqual(within("C:\\root", "C:\\root-old\\a", win32), null); + // The platform by default + deepStrictEqual(within(root, join(root, "a")), ["a"]); + }); + + test("contentTypeOf()", () => { + const cases: [string, string][] = [ + ["index.html", "text/html; charset=utf-8"], + ["/a/b/STYLE.CSS", "text/css; charset=utf-8"], + ["app.mjs", "text/javascript; charset=utf-8"], + ["data.json", "application/json"], + ["image.svg", "image/svg+xml"], + ["font.woff2", "font/woff2"], + ["module.wasm", "application/wasm"], + ["file.unknown", "application/octet-stream"], + ["noext", "application/octet-stream"], + [".bashrc", "application/octet-stream"], + ["constructor", "application/octet-stream"], + ["a.constructor", "application/octet-stream"], + ]; + for (const [name, type] of cases) { + deepStrictEqual(contentTypeOf(name), type, name); + } + }); + + test("with `root`", async () => { + const { + status: code, + headers, + body, + error, + } = await serve("index.html", { + root, + }); + deepStrictEqual([code, error, body.toString()], [200, null, html]); + const stats = await stat(join(root, "index.html")); + const etag = `W/"${stats.size.toString(16)}-${Math.floor(stats.mtimeMs).toString(16)}"`; + deepStrictEqual( + { + "content-type": headers["content-type"], + "content-length": headers["content-length"], + etag: headers.etag, + "x-content-type-options": headers["x-content-type-options"], + "accept-ranges": headers["accept-ranges"], + }, + { + "content-type": "text/html; charset=utf-8", + "content-length": `${html.length}`, + etag, + "x-content-type-options": "nosniff", + "accept-ranges": "bytes", + }, + ); + ok(typeof headers["last-modified"] === "string"); + + const nested = await serve("dir/nested.txt", { root: pathToFileURL(root) }); + deepStrictEqual([nested.status, nested.body.toString()], [200, "nested"]); + deepStrictEqual( + nested.headers["content-type"], + "text/plain; charset=utf-8", + ); + // A filesystem path: not URL-decoded + deepStrictEqual( + (await serve("a%20b", { root })).body.toString(), + "encoded", + ); + // A relative `root` + const relativeRoot = relative(process.cwd(), root); + deepStrictEqual(await status("index.html", { root: relativeRoot }), 200); + }); + + test("with `root` (404 outside of it)", async () => { + for (const path of [ + "/etc/passwd", + join(root, "index.html"), + "../outside.txt", + "dir/../index.html", + "./index.html", + "", + "dir/", + "dir//nested.txt", + "index\0.html", + // Symbolic links that lead out (also to a directory whose name starts + // with the name of the root) + "link-outside", + "link-dir-outside/secret.txt", + "link-old", + // The root itself, and directories + "self", + "dir", + // Missing (`ENOENT`, `ENOTDIR`, `ENAMETOOLONG`, `ELOOP`) + "missing.txt", + "index.html/x", + "x".repeat(1024), + "loop1", + ]) { + const { status: code, error } = await serve(path, { root }); + deepStrictEqual(code, 404, path); + ok(error instanceof BindenError, path); + } + // A symbolic link that stays inside + const inside = await serve("link-inside", { root }); + deepStrictEqual([inside.status, inside.body.toString()], [200, html]); + // Named by the path that is asked for + deepStrictEqual(inside.headers["content-type"], "application/octet-stream"); + }); + + test("with `root` (`dotfiles`)", async () => { + for (const path of [".secret", ".hidden/file.txt", "link-dot"]) { + deepStrictEqual(await status(path), 404, path); + deepStrictEqual(await status(path, { dotfiles: "ignore" }), 404, path); + deepStrictEqual(await status(path, { dotfiles: "deny" }), 403, path); + deepStrictEqual(await status(path, { dotfiles: "allow" }), 200, path); + } + // Before looking for the file + deepStrictEqual(await status(".missing", { dotfiles: "deny" }), 403); + deepStrictEqual(await status(".missing", { dotfiles: "allow" }), 404); + }); + + test("with `root` (invalid)", async () => { + const checks: [URL | string, ISendFileOptions, Error][] = [ + [ + pathToFileURL(join(root, "index.html")), + { root }, + new TypeError("With `root`, the path must be a relative string"), + ], + [ + "index.html", + { root: "" }, + new TypeError("`root` is not a non-empty string or a URL"), + ], + [ + "index.html", + { root: 1 as unknown as string }, + new TypeError("`root` is not a non-empty string or a URL"), + ], + [ + "index.html", + { root: new URL("https://example.com/") }, + new TypeError("Protocol https: is not supported"), + ], + ]; + for (const [path, options, expected] of checks) { + const { status: code, error } = await serve(path, options); + deepStrictEqual([code, error], [500, expected]); + } + // A root that does not exist is not a missing file + const { status: code, error } = await serve("index.html", { + root: join(tmp, "missing"), + }); + deepStrictEqual(code, 500); + deepStrictEqual((error as { code?: unknown }).code, "ENOENT"); + }); + + test("with `root` (errors of opening the file)", async () => { + const opening = async (code: string): Promise => { + const failing = Object.assign(new Error(code), { code }); + mock.method(fsp, "open", () => Promise.reject(failing)); + syncBuiltinESMExports(); + try { + return await serve("index.html", { root }); + } finally { + mock.restoreAll(); + syncBuiltinESMExports(); + } + }; + // E.g. a directory on Windows + const directory = await opening("EISDIR"); + deepStrictEqual(directory.status, 404); + ok(directory.error instanceof BindenError); + deepStrictEqual( + (directory.error.cause as { code?: unknown }).code, + "EISDIR", + ); + // Not a missing file + const denied = await opening("EACCES"); + deepStrictEqual(denied.status, 500); + deepStrictEqual((denied.error as { code?: unknown }).code, "EACCES"); + }); + + test( + "with `root` (a file replaced by a symbolic link once resolved)", + posixOnly, + async () => { + const target = join(root, "swapped.txt"); + await writeFile(target, "swapped"); + const { realpath: original } = fsp; + mock.method(fsp, "realpath", async (path: string) => { + const real = await original(path); + if (real === target) { + // Between the check and the open + await unlink(target); + await symlink(join(tmp, "outside.txt"), target); + } + return real; + }); + syncBuiltinESMExports(); + try { + const { status: code, body } = await serve("swapped.txt", { root }); + deepStrictEqual([code, body.toString()], [404, ""]); + } finally { + mock.restoreAll(); + syncBuiltinESMExports(); + await rm(target); + } + }, + ); + + test("with `root` (a backslash is a character)", posixOnly, async () => { + await writeFile(join(root, "back\\slash"), "backslash"); + try { + deepStrictEqual( + (await serve("back\\slash", { root })).body.toString(), + "backslash", + ); + } finally { + await rm(join(root, "back\\slash")); + } + }); + + test("with `root` (Windows)", windowsOnly, async () => { + deepStrictEqual( + (await serve("dir\\nested.txt", { root })).body.toString(), + "nested", + ); + for (const path of [ + "\\index.html", + "C:index.html", + "index.html:stream", + "dir\\..\\index.html", + ]) { + deepStrictEqual(await status(path), 404, path); + } + // A junction that leads out + await symlink(join(tmp, "private"), join(root, "junction"), "junction"); + try { + deepStrictEqual(await status("junction\\secret.txt"), 404); + } finally { + await rm(join(root, "junction")); + } + }); + + test("without `root` (a trusted path)", async () => { + const served = await serve(join(root, "a%20b")); + deepStrictEqual([served.status, served.body.toString()], [200, "encoded"]); + // Dotfiles and symbolic links are not checked + deepStrictEqual((await serve(join(root, ".secret"))).status, 200); + deepStrictEqual((await serve(join(root, "link-outside"))).status, 200); + const url = await serve(pathToFileURL(join(root, "style.CSS"))); + deepStrictEqual(url.headers["content-type"], "text/css; charset=utf-8"); + const { status: code, error } = await serve(1 as unknown as string); + deepStrictEqual( + [code, error], + [500, new TypeError("The path is not a string or a URL")], + ); + // A missing file is an error of the application, not a 404 + const missing = await serve(join(root, "missing.txt")); + deepStrictEqual(missing.status, 500); + deepStrictEqual((missing.error as { code?: unknown }).code, "ENOENT"); + }); + + test("options (invalid)", async () => { + const checks: [ISendFileOptions, Error][] = [ + [ + { etag: 1 as unknown as boolean }, + new TypeError("`etag` is not a boolean"), + ], + [ + { last_modified: "no" as unknown as boolean }, + new TypeError("`last_modified` is not a boolean"), + ], + [ + { immutable: 1 as unknown as boolean, max_age: 1 }, + new TypeError("`immutable` is not a boolean"), + ], + [ + { dotfiles: "hide" as "deny" }, + new TypeError("`dotfiles` is not one of allow, deny and ignore"), + ], + [ + { validator: "abc" }, + new TypeError("`validator` is not a strong entity-tag"), + ], + [ + { validator: `W/"abc"` }, + new TypeError("`validator` is not a strong entity-tag"), + ], + [ + { validator: `"a"b"` }, + new TypeError("`validator` is not a strong entity-tag"), + ], + [ + { cache_control: "no-store", max_age: 1 }, + new TypeError("`cache_control` excludes `max_age` and `immutable`"), + ], + [ + { cache_control: "no-store", immutable: false }, + new TypeError("`cache_control` excludes `max_age` and `immutable`"), + ], + [ + { cache_control: "no-store\r\nX: y" }, + new TypeError("`cache_control` is not a valid field value"), + ], + [ + { cache_control: "" }, + new TypeError("`cache_control` is not a valid field value"), + ], + [{ max_age: 1.5 }, new TypeError("`max_age` is not an integer")], + [ + { max_age: "1" as unknown as number }, + new TypeError("`max_age` is not an integer"), + ], + [{ max_age: -1 }, new RangeError("`max_age` is negative")], + [{ immutable: true }, new TypeError("`immutable` needs `max_age`")], + [{ content_type: "text" }, new TypeError("Invalid media type: text")], + [ + { content_type: true as unknown as string }, + new TypeError("`content_type` is not a string or `false`"), + ], + ]; + for (const [options, expected] of checks) { + const { status: code, error } = await serve( + join(root, "index.html"), + options, + ); + deepStrictEqual([code, error], [500, expected]); + } + }); + + test("options (`ETag`)", async () => { + const file = join(root, "index.html"); + const send = async ( + options: ISendFileOptions, + headers: Record = {}, + etag = "", + ): Promise<[number, unknown, string]> => { + const handled = server.handle(async (_request, response) => { + if (etag) { + response.setHeader("ETag", etag); + } + await response.sendFile(file, options); + }); + const reply = await server.request({ headers }); + deepStrictEqual(await handled, null); + return [reply.status, reply.headers.etag ?? null, reply.body.toString()]; + }; + const stats = await stat(file); + const weak = `W/"${stats.size.toString(16)}-${Math.floor(stats.mtimeMs).toString(16)}"`; + const range = { Range: "bytes=0-1" }; + + deepStrictEqual(await send({}), [200, weak, html]); + deepStrictEqual(await send({}, { "If-None-Match": weak }), [304, weak, ""]); + // A weak tag does not satisfy `If-Range` + deepStrictEqual(await send({}, { ...range, "If-Range": weak }), [ + 200, + weak, + html, + ]); + deepStrictEqual(await send({ etag: false }), [200, null, html]); + deepStrictEqual(await send({ etag: false }, { "If-None-Match": "*" }), [ + 304, + null, + "", + ]); + // The `ETag` of the application stays + deepStrictEqual(await send({}, {}, `"app"`), [200, `"app"`, html]); + // A strong validator of the application + const validator = `"sha-abc"`; + deepStrictEqual(await send({ validator }, {}, `"app"`), [ + 200, + validator, + html, + ]); + deepStrictEqual( + await send( + { validator, etag: false }, + { ...range, "If-Range": validator }, + ), + [206, validator, html.slice(0, 2)], + ); + deepStrictEqual(await send({ validator }, { "If-Match": validator }), [ + 200, + validator, + html, + ]); + deepStrictEqual(await send({ validator }, { "If-None-Match": validator }), [ + 304, + validator, + "", + ]); + }); + + test("options (`last_modified`)", async () => { + const file = join(tmp, "outside.txt"); + // A strong `Last-Modified` + const seconds = Math.floor(Date.now() / 1000) * 1000 - 3600 * 1000; + await utimes(file, new Date(seconds), new Date(seconds)); + const lm = new Date(seconds).toUTCString(); + const old = new Date(seconds - 1000).toUTCString(); + const range = { Range: "bytes=0-1" }; + const send = async ( + options: ISendFileOptions, + headers: Record, + ): Promise<[number, unknown]> => { + const served = await serve(file, options, { headers }); + return [served.status, served.headers["last-modified"] ?? null]; + }; + + deepStrictEqual(await send({}, { "If-Modified-Since": lm }), [304, lm]); + deepStrictEqual(await send({}, { "If-Unmodified-Since": old }), [412, lm]); + deepStrictEqual(await send({}, { ...range, "If-Range": lm }), [206, lm]); + // Without it, the dates do not apply + const none = { last_modified: false }; + deepStrictEqual(await send(none, {}), [200, null]); + deepStrictEqual(await send(none, { "If-Modified-Since": lm }), [200, null]); + deepStrictEqual(await send(none, { "If-Unmodified-Since": old }), [ + 200, + null, + ]); + deepStrictEqual(await send(none, { ...range, "If-Range": lm }), [ + 200, + null, + ]); + }); + + test("options (`Cache-Control`)", async () => { + const file = join(root, "index.html"); + const cacheControl = async ( + options: ISendFileOptions, + headers: Record = {}, + ): Promise<[number, unknown]> => { + const served = await serve(file, options, { headers }); + return [served.status, served.headers["cache-control"] ?? null]; + }; + deepStrictEqual(await cacheControl({}), [200, null]); + deepStrictEqual(await cacheControl({ cache_control: "no-cache" }), [ + 200, + "no-cache", + ]); + deepStrictEqual(await cacheControl({ max_age: 0 }), [ + 200, + "public, max-age=0", + ]); + deepStrictEqual( + await cacheControl({ max_age: 31536000, immutable: true }), + [200, "public, max-age=31536000, immutable"], + ); + deepStrictEqual(await cacheControl({ max_age: 60, immutable: false }), [ + 200, + "public, max-age=60", + ]); + // Also on `304 Not Modified` + deepStrictEqual( + await cacheControl({ max_age: 60 }, { "If-None-Match": "*" }), + [304, "public, max-age=60"], + ); + }); + + test("options (`content_type`)", async () => { + const type = async ( + path: string, + options: ISendFileOptions = {}, + set = "", + ): Promise => { + const handled = server.handle(async (_request, response) => { + if (set) { + response.setHeader("Content-Type", set); + } + await response.sendFile(join(root, path), options); + }); + const { headers } = await server.request(); + deepStrictEqual(await handled, null); + return headers["content-type"] ?? null; + }; + deepStrictEqual(await type("style.CSS"), "text/css; charset=utf-8"); + deepStrictEqual(await type("data.unknown"), "application/octet-stream"); + deepStrictEqual(await type("noext"), "application/octet-stream"); + deepStrictEqual( + await type("noext", { content_type: " Text/Plain; Charset=ISO-8859-1" }), + "text/plain; charset=iso-8859-1", + ); + deepStrictEqual(await type("index.html", { content_type: false }), null); + // The `Content-Type` of the application stays + deepStrictEqual(await type("index.html", {}, "text/x-app"), "text/x-app"); + deepStrictEqual( + await type("index.html", { content_type: false }, "text/x-app"), + "text/x-app", + ); + deepStrictEqual( + await type("index.html", { content_type: "text/x-option" }, "text/x-app"), + "text/x-option", + ); + }); +}); From 819a3b1b8fa892ce22ce5b39cc5c7f6d0675cba2 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:26:22 +0300 Subject: [PATCH 128/675] fix: reject an unknown encoding before sending in binden send(string, encoding) with an unknown encoding threw inside the write and left the response started without an end. It now rejects with a TypeError before anything is sent, so that the error can still be replied. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/response.ts | 7 +++++++ packages/binden/test/response-commit.test.ts | 12 +++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index c947dabf..3412605e 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -305,6 +305,13 @@ export class BindenResponse< BindenResponse.#discard(data); } return Promise.resolve(); + } else if ( + typeof encoding !== "undefined" && + !Buffer.isEncoding(encoding) + ) { + return Promise.reject( + new TypeError(`Unknown encoding: ${String(encoding)}`), + ); } this.#started = true; diff --git a/packages/binden/test/response-commit.test.ts b/packages/binden/test/response-commit.test.ts index 0b37c697..7b2ac462 100644 --- a/packages/binden/test/response-commit.test.ts +++ b/packages/binden/test/response-commit.test.ts @@ -1,4 +1,4 @@ -import { deepStrictEqual, ok, throws } from "node:assert"; +import { deepStrictEqual, ok, rejects, throws } from "node:assert"; import { IncomingMessage, type OutgoingHttpHeader, @@ -338,4 +338,14 @@ suite("BindenResponse (headers, flush, ETag and seal)", () => { "ERR_STREAM_PREMATURE_CLOSE", ); }); + + test(".send() (rejects an unknown encoding before the response)", async () => { + const response = detached(); + await rejects( + () => response.send("data", "nope" as BufferEncoding), + new TypeError("Unknown encoding: nope"), + ); + ok(!response.started); + ok(!response.hasHeader("Content-Length")); + }); }); From 5435627c232a4a0cb89fc122b610841c10c439fc Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:26:56 +0300 Subject: [PATCH 129/675] feat: add the dynamic ETag of the response in binden With the `etag` option of the response (off by default, which Binden will set from its own option in a subclass), a 200 response to GET or HEAD whose body is a Buffer or a string gets a strong ETag of the bytes that are sent (SHA-256, base64url) unless it has one, and becomes 304 Not Modified, with the same validators, Vary and Cache-Control, when If-None-Match matches by the weak comparison. validateResponseOptions() checks the options. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/response.ts | 75 +++++++- packages/binden/test/response-commit.test.ts | 175 ++++++++++++++++++- 2 files changed, 248 insertions(+), 2 deletions(-) diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index 3412605e..645c257c 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { IncomingMessage, type OutgoingHttpHeader, @@ -7,7 +8,7 @@ import { import { Readable } from "node:stream"; import ContentType from "./headers/content-type.js"; import Cookie, { type ICookie } from "./headers/cookie.js"; -import { type ISendFileOptions, sendFileTo } from "./send-file.js"; +import { type ISendFileOptions, matches, sendFileTo } from "./send-file.js"; import { isStatus } from "./status.js"; export const ct_json = "application/json"; @@ -19,12 +20,37 @@ export type IHeadersValue = number | string | readonly string[]; export type IHeaders = Record; +/** The options of the responses of an application (`Binden` sets them) */ +export interface IResponseOptions { + /** + * A strong `ETag` (the SHA-256 of the body) for the `200` responses to `GET` + * and `HEAD` sent from a `Buffer` or a string, and `304 Not Modified` when + * `If-None-Match` matches it + */ + readonly etag: boolean; +} + /** The attributes of a cookie to remove (see `BindenResponse.clearCookie()`) */ export type IClearCookie = Pick< ICookie, "domain" | "partitioned" | "path" | "same_site" | "secure" >; +/** + * Validates the options of the responses (`etag` is `false` by default): a + * `TypeError` for an invalid one + */ +export function validateResponseOptions({ + etag = false, +}: { readonly etag?: boolean | undefined } = {}): Readonly { + if (typeof etag !== "boolean") { + throw new TypeError("`etag` is not a boolean"); + } + return Object.freeze({ etag }); +} + +const defaults = validateResponseOptions(); + /** The characters of `attr-char` (RFC 8187, section 3.2.1) */ const attrChar = /^[\w!#$&+.^`|~-]$/u; @@ -91,6 +117,15 @@ export class BindenResponse< return this.#started || this.headersSent || this.writableEnded; } + /** + * The options of the response (no dynamic `ETag` by default): `Binden` + * overrides them in a subclass of its own + */ + // eslint-disable-next-line class-methods-use-this -- overridden by subclasses + protected get options(): Readonly { + return defaults; + } + /** Set the status code: an integer from 100 to 599 */ public status(code: number): this { if (!isStatus(code)) { @@ -344,6 +379,14 @@ export class BindenResponse< ? data : data.toString(); + if (this.#notModified(body, encoding)) { + // The validators and the other headers of the `200` response, no body + this.statusCode = 304; + this.#frame(null); + this.end(); + return done; + } + if ( !this.headersSent && typeof this.getHeader("Content-Type") === "undefined" && @@ -380,6 +423,36 @@ export class BindenResponse< }); } + /** + * With the dynamic `ETag` (see `options`), sets the strong `ETag` of a body + * of a `200` response to `GET` or `HEAD` (unless it has one already), and + * tells whether `If-None-Match` matches it (by the weak comparison) + */ + #notModified(body: Buffer | string, encoding?: BufferEncoding): boolean { + const { method = "GET", headers } = this.req; + if ( + this.headersSent || + !this.options.etag || + this.statusCode !== 200 || + (method !== "GET" && method !== "HEAD") + ) { + return false; + } + let etag = this.getHeader("ETag"); + if (typeof etag === "undefined") { + const bytes = + typeof body === "string" ? Buffer.from(body, encoding) : body; + etag = `"${createHash("sha256").update(bytes).digest("base64url")}"`; + this.setHeader("ETag", etag); + } + const ifNoneMatch = headers["if-none-match"]; + return ( + typeof ifNoneMatch === "string" && + typeof etag === "string" && + matches(ifNoneMatch, etag, false) + ); + } + /** * Send a JSON value as `application/json`. A value that has no JSON text * (`undefined`, a function or a symbol), a `bigint` or a cyclic value diff --git a/packages/binden/test/response-commit.test.ts b/packages/binden/test/response-commit.test.ts index 7b2ac462..73176b2c 100644 --- a/packages/binden/test/response-commit.test.ts +++ b/packages/binden/test/response-commit.test.ts @@ -1,4 +1,5 @@ import { deepStrictEqual, ok, rejects, throws } from "node:assert"; +import { createHash } from "node:crypto"; import { IncomingMessage, type OutgoingHttpHeader, @@ -10,7 +11,12 @@ import { Socket } from "node:net"; import { Readable } from "node:stream"; import { afterEach, suite, test } from "node:test"; -import { BindenResponse, Cookie } from "../index.js"; +import { + BindenResponse, + Cookie, + type IResponseOptions, + validateResponseOptions, +} from "../index.js"; import { type ITestServer, type IListenOptions, listen } from "./http.js"; /** A response without a connection: nothing is actually sent */ @@ -26,6 +32,19 @@ function settle(promise: Promise): Promise { ); } +/** A response with the dynamic `ETag` */ +class EtagResponse extends BindenResponse { + // eslint-disable-next-line class-methods-use-this -- the options of a subclass + protected override get options(): Readonly { + return validateResponseOptions({ etag: true }); + } +} + +/** The strong `ETag` of a body */ +function etagOf(body: Buffer | string): string { + return `"${createHash("sha256").update(body).digest("base64url")}"`; +} + /** `undefined`, which the linter does not let the tests write */ const none = ({} as { value?: undefined }).value; @@ -339,6 +358,160 @@ suite("BindenResponse (headers, flush, ETag and seal)", () => { ); }); + test("validateResponseOptions()", () => { + deepStrictEqual(validateResponseOptions(), { etag: false }); + deepStrictEqual(validateResponseOptions({ etag: none }), { + etag: false, + }); + const options = validateResponseOptions({ etag: true }); + deepStrictEqual(options, { etag: true }); + ok(Object.isFrozen(options)); + throws( + () => validateResponseOptions({ etag: 1 as unknown as boolean }), + new TypeError("`etag` is not a boolean"), + ); + }); + + test("dynamic ETag", async () => { + const app = await start({ Response: EtagResponse }); + const send = async ( + reply: (response: BindenResponse) => unknown, + options: Parameters[0] = {}, + ): Promise<[number, string | null, string]> => { + const handled = app.handle((_request, response) => reply(response)); + const { status, headers, body } = await app.request(options); + deepStrictEqual(await handled, null); + return [status, headers.etag ?? null, body.toString()]; + }; + const text = "Hello, ETag"; + const tag = etagOf(text); + + deepStrictEqual(await send((r): unknown => r.send(text)), [200, tag, text]); + deepStrictEqual( + await send((r): unknown => r.send(text), { method: "HEAD" }), + [200, tag, ""], + ); + // The bytes that are sent + const base64 = Buffer.from(text).toString("base64"); + deepStrictEqual(await send((r): unknown => r.send(base64, "base64")), [ + 200, + tag, + text, + ]); + deepStrictEqual(await send((r): unknown => r.send(Buffer.from(text))), [ + 200, + tag, + text, + ]); + deepStrictEqual(await send((r): unknown => r.json({ a: 1 })), [ + 200, + etagOf(`{"a":1}`), + `{"a":1}`, + ]); + deepStrictEqual(await send((r): unknown => r.send(12)), [ + 200, + etagOf("12"), + "12", + ]); + + // Not for other statuses, methods, bodies, or sent headers + deepStrictEqual(await send((r): unknown => r.status(201).send(text)), [ + 201, + null, + text, + ]); + deepStrictEqual( + await send((r): unknown => r.send(text), { method: "POST" }), + [200, null, text], + ); + deepStrictEqual(await send((r): unknown => r.send()), [200, null, ""]); + deepStrictEqual(await send((r): unknown => r.send(Readable.from([text]))), [ + 200, + null, + text, + ]); + deepStrictEqual( + await send((r): unknown => { + r.flushHeaders(); + return r.send(text); + }), + [200, null, text], + ); + // The `ETag` of the application stays + deepStrictEqual( + await send((r): unknown => r.setHeader("ETag", `W/"app"`).send(text)), + [200, `W/"app"`, text], + ); + }); + + test("dynamic ETag (`If-None-Match`)", async () => { + const app = await start({ Response: EtagResponse }); + const text = "Hello, ETag"; + const tag = etagOf(text); + const check = async ( + ifNoneMatch: string, + { method = "GET", etag = "" } = {}, + ): Promise<[number, Record, string]> => { + const handled = app.handle(async (_request, response) => { + response.set({ + "Cache-Control": "max-age=60", + Vary: "Accept", + "Content-Length": 99, + }); + if (etag) { + response.setHeader("ETag", etag); + } + await response.text(text); + }); + const { status, headers, body } = await app.request({ + method, + headers: { "If-None-Match": ifNoneMatch }, + }); + deepStrictEqual(await handled, null); + return [ + status, + { + etag: headers.etag, + vary: headers.vary, + "cache-control": headers["cache-control"], + "content-length": headers["content-length"] ?? null, + }, + body.toString(), + ]; + }; + const notModified = { + etag: tag, + vary: "Accept", + "cache-control": "max-age=60", + "content-length": null, + }; + + // The weak comparison + for (const value of [tag, `W/${tag}`, `"x", ${tag}`, "*"]) { + deepStrictEqual(await check(value), [304, notModified, ""], value); + } + deepStrictEqual(await check(tag, { method: "HEAD" }), [ + 304, + notModified, + "", + ]); + const [status, headers, body] = await check(`"other"`); + deepStrictEqual([status, headers.etag, body], [200, tag, text]); + deepStrictEqual((await check(`"other"`, { method: "POST" }))[0], 200); + // The `ETag` of the application + deepStrictEqual((await check(`"app"`, { etag: `W/"app"` }))[0], 304); + }); + + test("no dynamic ETag by default", async () => { + const app = await start(); + const handled = app.handle((_request, response) => response.send("data")); + const { headers } = await app.request({ + headers: { "If-None-Match": "*" }, + }); + deepStrictEqual(await handled, null); + deepStrictEqual(headers.etag ?? null, null); + }); + test(".send() (rejects an unknown encoding before the response)", async () => { const response = detached(); await rejects( From 9751fd16d27e310aae37e4413cc9ba383ace01ef Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:27:31 +0300 Subject: [PATCH 130/675] feat: add redirect() to the response in binden A redirect to a location taken from the request is an open redirect unless the location is checked. redirect() allows only relative references and absolute URLs of the origin of the request (BindenRequest.URL) unless `allow_external` is set (also for network-path references), and never userinfo, controls, whitespace, backslashes or schemes other than http and https. Location is the WHATWG URL serialization (a relative reference stays relative, and a path that starts with `//` gets `/.`); the body is empty. Co-Authored-By: Claude Opus 5.5 --- packages/binden/index.ts | 1 + packages/binden/src/redirect.ts | 85 +++++++ packages/binden/src/response.ts | 55 ++++ packages/binden/test/redirect.test.ts | 345 ++++++++++++++++++++++++++ 4 files changed, 486 insertions(+) create mode 100644 packages/binden/src/redirect.ts create mode 100644 packages/binden/test/redirect.test.ts diff --git a/packages/binden/index.ts b/packages/binden/index.ts index c1f0cd60..600a4883 100644 --- a/packages/binden/index.ts +++ b/packages/binden/index.ts @@ -17,6 +17,7 @@ export * from "./src/binden.js"; export * from "./src/middleware.js"; export * from "./src/request.js"; export * from "./src/response.js"; +export type { IRedirectOptions, IRedirectStatus } from "./src/redirect.js"; export type { ISendFileOptions } from "./src/send-file.js"; export * from "./src/router.js"; export { serializers } from "./src/logger.js"; diff --git a/packages/binden/src/redirect.ts b/packages/binden/src/redirect.ts new file mode 100644 index 00000000..4e0eb6dc --- /dev/null +++ b/packages/binden/src/redirect.ts @@ -0,0 +1,85 @@ +/** The status codes of a redirect */ +export type IRedirectStatus = 301 | 302 | 303 | 307 | 308; + +/** The options of `BindenResponse.redirect()` */ +export interface IRedirectOptions { + /** + * Allow a location on another origin, or a network-path reference (`//host`): + * `false` by default, so that a location taken from the request cannot send + * the client to another site (an open redirect) + */ + readonly allow_external?: boolean | undefined; +} + +const statuses: ReadonlySet = new Set([301, 302, 303, 307, 308]); + +/** Whether a value is the status code of a redirect */ +export function isRedirectStatus(status: unknown): status is IRedirectStatus { + return statuses.has(status); +} + +/** A scheme (RFC 3986, section 3.1) */ +const scheme = /^[a-z][\d+.a-z-]*:/iu; + +/** Characters that a location never has: controls, whitespace and backslashes */ +const forbidden = /[\p{Cc}\s\\]/u; + +/** + * The `Location` of a redirect: the WHATWG URL serialization of `location`, + * resolved against the URL of the request (`base`). A relative reference stays + * relative (its path, query and fragment). Locations on other origins than + * `origin` (`null` when it is not known) and network-path references need + * `allow_external`; userinfo, controls, whitespace, backslashes and schemes + * other than `http` and `https` throw a `TypeError`. + */ +export function redirectLocation( + location: URL | string, + base: URL, + origin: string | null, + allow_external: boolean, +): string { + const input = location instanceof URL ? location.href : location; + if (typeof input !== "string" || input === "") { + throw new TypeError("The location is not a non-empty string or a URL"); + } else if (forbidden.test(input)) { + throw new TypeError( + "The location has a control character, whitespace or a backslash", + ); + } + + const absolute = scheme.test(input); + const network = !absolute && input.startsWith("//"); + // An `@` in the authority of an absolute URL or a network-path reference, + // which the URL parser may find after any number of slashes (also an empty + // userinfo, which its serialization would drop). A relative reference has + // the authority of the request, which has none. + const userinfo = + (absolute || network) && + /^[^/?#]*@/u.test(input.replace(scheme, "").replace(/^\/+/u, "")); + const url = new URL(input, base); + + if (userinfo) { + throw new TypeError("The location has userinfo"); + } else if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new TypeError(`The location has the scheme ${url.protocol}`); + } else if ( + !allow_external && + (network || (absolute && url.origin !== origin)) + ) { + throw new TypeError( + "The location is external: set `allow_external` to allow it", + ); + } + + if (absolute) { + return url.href; + } else if (network) { + return url.href.slice(url.protocol.length); + } + // A path that starts with `//` would be read as an authority (WHATWG URL, + // section 4.5): `/.` keeps it a path + const path = url.pathname.startsWith("//") + ? `/.${url.pathname}` + : url.pathname; + return `${path}${url.search}${url.hash}`; +} diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index 645c257c..9e834d72 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -8,6 +8,13 @@ import { import { Readable } from "node:stream"; import ContentType from "./headers/content-type.js"; import Cookie, { type ICookie } from "./headers/cookie.js"; +import { + type IRedirectOptions, + type IRedirectStatus, + isRedirectStatus, + redirectLocation, +} from "./redirect.js"; +import { BindenRequest } from "./request.js"; import { type ISendFileOptions, matches, sendFileTo } from "./send-file.js"; import { isStatus } from "./status.js"; @@ -499,6 +506,54 @@ export class BindenResponse< return this.setHeader("Content-Type", ct_form).send(params.toString()); } + /** + * Redirect (`302 Found` by default) to a path or a URL of the same origin as + * the request, or with `allow_external` to another origin, with an empty + * body. `Location` is the WHATWG URL serialization of the location (a + * relative one stays relative). A location with userinfo, a control + * character, whitespace, a backslash or a scheme other than `http` and + * `https` rejects with a `TypeError`, as does an external one without + * `allow_external`. + */ + public async redirect( + location: URL | string, + status: IRedirectStatus = 302, + { allow_external = false }: IRedirectOptions = {}, + ): Promise { + if (!isRedirectStatus(status)) { + throw new TypeError(`Status code ${String(status)} is not a redirect`); + } else if (typeof allow_external !== "boolean") { + throw new TypeError("`allow_external` is not a boolean"); + } + const { base, origin } = this.#base(); + const value = redirectLocation(location, base, origin, allow_external); + await this.status(status) + .set({ Location: value, "Content-Length": 0 }) + .send(); + } + + /** + * The URL that a location is resolved against, with the origin of the + * request: the URL of a `BindenRequest`, otherwise the path of the request + * on an origin that is not known (no absolute URL is of the same origin) + */ + #base(): { base: URL; origin: string | null } { + if (this.req instanceof BindenRequest) { + const base = this.req.URL; + return { base, origin: base.origin }; + } + const base = new URL("http://binden.invalid"); + const target = String(this.req.url); + if (target.startsWith("/")) { + // Set as a path: `//host/x` is a path, not an authority + const index = target.indexOf("?"); + const end = index === -1 ? target.length : index; + base.pathname = target.slice(0, end); + base.search = target.slice(end); + } + return { base, origin: null }; + } + /** * Send the headers, then resolve once what has been written so far is * flushed to the connection; rejects when the response is closed before diff --git a/packages/binden/test/redirect.test.ts b/packages/binden/test/redirect.test.ts new file mode 100644 index 00000000..8ab7e698 --- /dev/null +++ b/packages/binden/test/redirect.test.ts @@ -0,0 +1,345 @@ +import { deepStrictEqual, ok } from "node:assert"; +import { IncomingMessage } from "node:http"; +import { Socket } from "node:net"; +import { afterEach, suite, test } from "node:test"; + +import { BindenResponse, type IRedirectStatus } from "../index.js"; +import { redirectLocation } from "../src/redirect.js"; +import { type ITestServer, listen } from "./http.js"; + +const base = new URL("http://example.com:8080/dir/page?q=1"); +const { origin } = base; + +/** The `Location` of a redirect from `base`, or the message of its error */ +function locate( + location: URL | string, + allow_external = false, + from: string | null = origin, +): string { + try { + return redirectLocation(location, base, from, allow_external); + } catch (error: unknown) { + ok(error instanceof TypeError, String(error)); + return `TypeError: ${error.message}`; + } +} + +const external = + "TypeError: The location is external: set `allow_external` to allow it"; +const userinfo = "TypeError: The location has userinfo"; +const forbidden = + "TypeError: The location has a control character, whitespace or a backslash"; + +suite("redirect", () => { + let server: ITestServer | null = null; + + afterEach(async () => { + const current = server; + server = null; + await current?.close(); + }); + + test("redirectLocation() (relative references stay relative)", () => { + const cases: [string, string][] = [ + ["/", "/"], + ["/a/b?c=d#e", "/a/b?c=d#e"], + // Normalized and percent-encoded, as by the WHATWG URL parser + ["/a/../b/./c", "/b/c"], + ["/a/%2e%2e/b", "/b"], + [ + "/путь?ключ=з#ф", + "/%D0%BF%D1%83%D1%82%D1%8C?%D0%BA%D0%BB%D1%8E%D1%87=%D0%B7#%D1%84", + ], + ["/@me", "/@me"], + ["/a%2F%2Fb", "/a%2F%2Fb"], + // Resolved against the URL of the request + ["next", "/dir/next"], + ["../up", "/up"], + ["?page=2", "/dir/page?page=2"], + ["#top", "/dir/page?q=1#top"], + // A path that starts with `//` is not an authority + ["/.//evil.example", "/.//evil.example"], + ["/a/..//evil.example", "/.//evil.example"], + ["..//evil.example", "/.//evil.example"], + ]; + for (const [location, expected] of cases) { + deepStrictEqual(locate(location), expected, location); + // Also on an origin that is not known + deepStrictEqual(locate(location, false, null), expected, location); + } + }); + + test("redirectLocation() (absolute URLs)", () => { + // The same origin + deepStrictEqual( + locate("http://example.com:8080/x"), + "http://example.com:8080/x", + ); + deepStrictEqual( + locate("HTTP://EXAMPLE.com:8080"), + "http://example.com:8080/", + ); + deepStrictEqual( + locate(new URL("/x?y#z", base)), + "http://example.com:8080/x?y#z", + ); + deepStrictEqual(locate("http:same"), "http://example.com:8080/dir/same"); + + // Another origin (scheme, host or port) + for (const location of [ + "https://example.com:8080/", + "http://example.com/", + "http://other.example:8080/", + "https:other.example", + new URL("https://other.example/x"), + ]) { + deepStrictEqual(locate(location), external, String(location)); + } + deepStrictEqual( + locate("https://other.example/a%20b", true), + "https://other.example/a%20b", + ); + deepStrictEqual( + locate("https:other.example", true), + "https://other.example/", + ); + deepStrictEqual( + locate("https://xn--e1afmkfd.example/", true), + "https://xn--e1afmkfd.example/", + ); + deepStrictEqual( + locate("https://пример.example/", true), + "https://xn--e1afmkfd.example/", + ); + // No absolute URL is of an origin that is not known + deepStrictEqual(locate("http://example.com:8080/x", false, null), external); + deepStrictEqual( + locate("http://example.com:8080/x", true, null), + "http://example.com:8080/x", + ); + }); + + test("redirectLocation() (network-path references)", () => { + deepStrictEqual(locate("//other.example/x"), external); + // Even to the same host + deepStrictEqual(locate("//example.com:8080/x"), external); + deepStrictEqual( + locate("//other.example/x?y#z", true), + "//other.example/x?y#z", + ); + deepStrictEqual(locate("///other.example/x", true), "//other.example/x"); + }); + + test("redirectLocation() (never)", () => { + const cases: [URL | string, string][] = [ + ["http://user@example.com:8080/", userinfo], + ["http://user:pass@other.example/", userinfo], + ["http://@other.example/", userinfo], + ["http://:@example.com:8080/", userinfo], + ["//user@other.example/", userinfo], + ["https:@other.example", userinfo], + ["http:///user@other.example", userinfo], + [new URL("http://user:pass@example.com:8080/"), userinfo], + [ + // Not written as a literal, which the linter reports + ["javascript", "alert(1)"].join(":"), + "TypeError: The location has the scheme javascript:", + ], + ["data:text/html,x", "TypeError: The location has the scheme data:"], + ["mailto:a@b.example", userinfo], + ["ftp://other.example/", "TypeError: The location has the scheme ftp:"], + [ + new URL("file:///etc/passwd"), + "TypeError: The location has the scheme file:", + ], + ["/a b", forbidden], + [" /a", forbidden], + ["/a\t", forbidden], + ["/a\r\nX-Injected: 1", forbidden], + ["/a\u0000", forbidden], + ["/a\u007F", forbidden], + [`/a${String.fromCodePoint(0x85)}`, forbidden], + [`/a${String.fromCodePoint(0xa0)}`, forbidden], + [`/a${String.fromCodePoint(0x2028)}`, forbidden], + ["/\\evil.example", forbidden], + ["http:\\\\evil.example", forbidden], + ["", "TypeError: The location is not a non-empty string or a URL"], + [ + 1 as unknown as string, + "TypeError: The location is not a non-empty string or a URL", + ], + ["http://[::1", "TypeError: Invalid URL"], + ]; + for (const [location, expected] of cases) { + // Also with `allow_external` + deepStrictEqual(locate(location, true), expected, String(location)); + } + }); + + test(".redirect()", async () => { + server = await listen(); + const app = server; + const send = async ( + reply: (response: BindenResponse) => Promise, + method = "GET", + ): Promise<[number, unknown, unknown, string]> => { + const handled = app.handle((_request, response) => reply(response)); + const { status, headers, body } = await app.request({ + method, + path: "/from/here?x=1", + }); + deepStrictEqual(await handled, null); + return [ + status, + headers.location, + headers["content-length"], + body.toString(), + ]; + }; + + deepStrictEqual(await send((r) => r.redirect("/to")), [ + 302, + "/to", + "0", + "", + ]); + deepStrictEqual(await send((r) => r.redirect("next")), [ + 302, + "/from/next", + "0", + "", + ]); + for (const status of [301, 302, 303, 307, 308] as const) { + deepStrictEqual(await send((r) => r.redirect("/to", status)), [ + status, + "/to", + "0", + "", + ]); + } + // An empty body, also replacing what was set + deepStrictEqual( + await send( + (r) => r.setHeader("Content-Length", 99).redirect("/to"), + "HEAD", + ), + [302, "/to", "0", ""], + ); + // The origin of the request (`Host`) + deepStrictEqual(await send((r) => r.redirect(`${app.url}/x`)), [ + 302, + `${app.url}/x`, + "0", + "", + ]); + deepStrictEqual( + await send((r) => + r.redirect("https://other.example/", 303, { allow_external: true }), + ), + [303, "https://other.example/", "0", ""], + ); + }); + + test(".redirect() (rejects before the response)", async () => { + server = await listen(); + const app = server; + const check = async ( + reply: (response: BindenResponse) => Promise, + expected: Error, + ): Promise => { + const handled = app.handle(async (_request, response) => { + const error = await reply(response).then( + () => null, + (reason: unknown) => reason, + ); + deepStrictEqual(error, expected); + // Nothing set nor started: the error can be replied + ok(!response.started); + ok(!response.hasHeader("Location")); + deepStrictEqual(response.statusCode, 200); + await response.send("replied"); + }); + const { body } = await app.request(); + deepStrictEqual(await handled, null); + deepStrictEqual(body.toString(), "replied"); + }; + await check( + (r) => r.redirect("https://other.example/"), + new TypeError( + "The location is external: set `allow_external` to allow it", + ), + ); + for (const status of [300, 304, 200, "302"]) { + await check( + (r) => r.redirect("/to", status as IRedirectStatus), + new TypeError(`Status code ${status} is not a redirect`), + ); + } + await check( + (r) => + r.redirect("/to", 302, { allow_external: "yes" as unknown as boolean }), + new TypeError("`allow_external` is not a boolean"), + ); + }); + + test(".redirect() (a request that is not a `BindenRequest`)", async () => { + server = await listen({ plain: true }); + const app = server; + const send = async ( + reply: (response: BindenResponse) => Promise, + path = "/from/here?x=1", + ): Promise => { + const handled = app.handle(async (_request, response) => { + try { + await reply(response); + } finally { + // Replied when the redirect is refused + response.end(); + } + }); + const { headers } = await app.request({ path }); + return [await handled, headers.location]; + }; + deepStrictEqual(await send((r) => r.redirect("next")), [ + null, + "/from/next", + ]); + deepStrictEqual(await send((r) => r.redirect("#top")), [ + null, + "/from/here?x=1#top", + ]); + // The path of the request-target, not an authority + deepStrictEqual(await send((r) => r.redirect("#top"), "//from/here"), [ + null, + "/.//from/here#top", + ]); + // Its origin is not known + const [error] = (await send((r) => r.redirect(`${app.url}/x`))) as [ + unknown, + ]; + deepStrictEqual( + error, + new TypeError( + "The location is external: set `allow_external` to allow it", + ), + ); + + // Without a path of the request-target + const response = new BindenResponse(new IncomingMessage(new Socket())); + response.req.url = "*"; + // Never sent without a connection + response.redirect("next").catch(() => { + // Nothing to send it to + }); + deepStrictEqual(response.getHeader("Location"), "/next"); + deepStrictEqual(response.statusCode, 302); + + // Nor of the absolute-form + const absolute = new BindenResponse(new IncomingMessage(new Socket())); + absolute.req.url = "http://other.example/a/b"; + absolute.redirect("next").catch(() => { + // Nothing to send it to + }); + deepStrictEqual(absolute.getHeader("Location"), "/next"); + }); +}); From 66c34abcd754953b324c55b999d6d202453dbb11 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:28:22 +0300 Subject: [PATCH 131/675] feat: seal a response against late sends in binden Once a deadline replaces the response of a middleware, its late sends must neither fail nor write into the reply that replaced it. The internal `response[kSeal](late)` makes send(), json(), text(), html(), form(), redirect(), sendFile() and flush() resolve without writing anything while `late()` returns true (always without it, or when it throws), so that the pipeline can tell late callers from its own reply. Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/response.ts | 68 ++++++++++++++++---- packages/binden/src/seal.ts | 9 +++ packages/binden/test/response-commit.test.ts | 52 +++++++++++++++ 3 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 packages/binden/src/seal.ts diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index 9e834d72..29c54c1d 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -15,6 +15,7 @@ import { redirectLocation, } from "./redirect.js"; import { BindenRequest } from "./request.js"; +import { kSeal } from "./seal.js"; import { type ISendFileOptions, matches, sendFileTo } from "./send-file.js"; import { isStatus } from "./status.js"; @@ -90,11 +91,15 @@ function contentDisposition(filename?: string): string { /** A header field name (a token, RFC 9110, section 5.6.2) */ const fieldName = /^[!#$%&'*+\-.^_`|~\dA-Za-z]+$/u; +/** A late send is a no-op */ +const always = (): boolean => true; + export class BindenResponse< Request extends IncomingMessage = IncomingMessage, > extends ServerResponse { #cookies?: Set; #started = false; + readonly #seals: (() => boolean)[] = []; public constructor(...args: [request: Request, options?: unknown]) { // node:http passes its options as well @@ -332,6 +337,28 @@ export class BindenResponse< : [String(value)]; } + /** + * Seals the response against late sends: while `late()` returns `true` + * (always without it, or when it throws), the sending methods resolve without + * writing anything + * + * @internal + */ + public [kSeal](late: () => boolean = always): void { + this.#seals.push(late); + } + + /** Whether a send is late (see `[kSeal]()`) */ + #sealed(): boolean { + return this.#seals.some((late) => { + try { + return late(); + } catch { + return true; + } + }); + } + /** Send a response */ public send( data?: Buffer | Readable | bigint | number | string, @@ -341,7 +368,7 @@ export class BindenResponse< data?: Buffer | Readable | bigint | number | string, encoding?: BufferEncoding, ): Promise { - if (this.writableEnded) { + if (this.writableEnded || this.#sealed()) { if (data instanceof Readable) { // Nothing of it will be sent: release it (e.g. its file) BindenResponse.#discard(data); @@ -470,6 +497,9 @@ export class BindenResponse< data: T, stringify?: (data: T) => string, ): Promise { + if (this.#sealed()) { + return; + } const type = typeof data; if ( type === "undefined" || @@ -487,23 +517,32 @@ export class BindenResponse< await this.setHeader("Content-Type", ct_json).send(body); } - /** Send response as `text/plain; charset=utf-8` */ - public text(data: string): Promise { - return this.setHeader("Content-Type", `${ct_text}; charset=utf-8`).send( + /** Send text as `text/plain; charset=utf-8` */ + public async text(data: string): Promise { + if (this.#sealed()) { + return; + } + await this.setHeader("Content-Type", `${ct_text}; charset=utf-8`).send( data, ); } - /** Send response as `text/html; charset=utf-8` */ - public html(data: string): Promise { - return this.setHeader("Content-Type", `${ct_html}; charset=utf-8`).send( + /** Send text as `text/html; charset=utf-8` */ + public async html(data: string): Promise { + if (this.#sealed()) { + return; + } + await this.setHeader("Content-Type", `${ct_html}; charset=utf-8`).send( data, ); } - /** Send response as `application/x-www-form-urlencoded` */ - public form(params: URLSearchParams): Promise { - return this.setHeader("Content-Type", ct_form).send(params.toString()); + /** Send `URLSearchParams` as `application/x-www-form-urlencoded` */ + public async form(params: URLSearchParams): Promise { + if (this.#sealed()) { + return; + } + await this.setHeader("Content-Type", ct_form).send(params.toString()); } /** @@ -520,7 +559,9 @@ export class BindenResponse< status: IRedirectStatus = 302, { allow_external = false }: IRedirectOptions = {}, ): Promise { - if (!isRedirectStatus(status)) { + if (this.#sealed()) { + return; + } else if (!isRedirectStatus(status)) { throw new TypeError(`Status code ${String(status)} is not a redirect`); } else if (typeof allow_external !== "boolean") { throw new TypeError("`allow_external` is not a boolean"); @@ -561,7 +602,7 @@ export class BindenResponse< * the compressor as well. */ public flush(): Promise { - if (this.writableFinished) { + if (this.#sealed() || this.writableFinished) { return Promise.resolve(); } else if (this.writableEnded || this.destroyed) { return this.#finished(() => false); @@ -808,6 +849,9 @@ export class BindenResponse< path: URL | string, options?: ISendFileOptions, ): Promise { + if (this.#sealed()) { + return; + } // Started at once (see `.started`), unless it fails before sending anything this.#started = true; try { diff --git a/packages/binden/src/seal.ts b/packages/binden/src/seal.ts new file mode 100644 index 00000000..d5ba067f --- /dev/null +++ b/packages/binden/src/seal.ts @@ -0,0 +1,9 @@ +/** + * The key of the internal method that seals a response against late sends + * (e.g. of a middleware once its deadline has passed): `response[kSeal](late)` + * makes the sending methods resolve without writing anything while `late()` + * returns `true` (always without it). Not part of the public API. + * + * @internal + */ +export const kSeal: unique symbol = Symbol("binden.seal"); diff --git a/packages/binden/test/response-commit.test.ts b/packages/binden/test/response-commit.test.ts index 73176b2c..be7f371b 100644 --- a/packages/binden/test/response-commit.test.ts +++ b/packages/binden/test/response-commit.test.ts @@ -17,6 +17,7 @@ import { type IResponseOptions, validateResponseOptions, } from "../index.js"; +import { kSeal } from "../src/seal.js"; import { type ITestServer, type IListenOptions, listen } from "./http.js"; /** A response without a connection: nothing is actually sent */ @@ -521,4 +522,55 @@ suite("BindenResponse (headers, flush, ETag and seal)", () => { ok(!response.started); ok(!response.hasHeader("Content-Length")); }); + + test("[kSeal]() (late sends write nothing)", async () => { + const response = detached(); + response[kSeal](); + const source = Readable.from(["data"]); + const sends: Promise[] = [ + response.send("data"), + response.send(source), + response.json({ a: 1 }), + // Not even validated + response.json(none), + response.text("text"), + response.html("

"), + response.form(new URLSearchParams({ a: "1" })), + response.redirect("https://example.com/"), + response.sendFile("/nonexistent/file"), + response.flush(), + ]; + for (const sent of sends) { + deepStrictEqual(await settle(sent), null); + } + ok(!response.started); + ok(!response.headersSent); + deepStrictEqual(response.getHeaderNames(), []); + deepStrictEqual(response.statusCode, 200); + // Released + ok(source.destroyed); + }); + + test("[kSeal]() (while it is late)", async () => { + const app = await start(); + let late = true; + const handled = app.handle(async (_request, response) => { + response[kSeal](() => late); + await response.json({ late: true }); + ok(!response.started); + late = false; + await response.json({ late: false }); + }); + const { body } = await app.request(); + deepStrictEqual(await handled, null); + deepStrictEqual(body.toString(), `{"late":false}`); + + // A check that throws is late + const response = detached(); + response[kSeal](() => { + throw new Error("check"); + }); + deepStrictEqual(await settle(response.text("late")), null); + ok(!response.started); + }); }); From 753ba8678ad14fc5e36ed27671b02ac9eff329ca Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:28:51 +0300 Subject: [PATCH 132/675] docs: document the response helpers in binden The README describes the new helpers of BindenResponse (type, attachment, vary, clearCookie, redirect, flush), the JSON values, the cookies of every header commit, the dynamic ETag, and the root, dotfiles, validators and caching options of sendFile() with the limits of its containment guarantee. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 99 +++++++++++++++++++++++++++++++++++---- 1 file changed, 89 insertions(+), 10 deletions(-) diff --git a/packages/binden/README.md b/packages/binden/README.md index f4fc2af8..dc2eab71 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -626,7 +626,7 @@ const server = createServer({ ServerResponse: BindenResponse }); - `.started` - whether the response has been started: by `.send()` or `.sendFile()`, by a stream piped into it, or by its headers being sent -- `.cookies` - The `.send()` method will add cookies to the response +- `.cookies` - the cookies to send. They are added to the `Set-Cookie` header once, when the headers are sent by any path (`.send()`, `writeHead()` with any of its arguments, `write()`, `end()`, `flushHeaders()` or a piped stream), after the `Set-Cookie` values set on the response or given to `writeHead()` ```typescript import { randomUUID } from "crypto"; @@ -639,6 +639,14 @@ response.cookies.add(cookie); await response.send("Check the `Set-Cookie` header for a random UUID"); ``` +- `.clearCookie()` - remove a cookie from the client: an empty value that expires at once, with the `domain`, `path`, `secure`, `same_site` and `partitioned` of the cookie to remove (it replaces a cookie of the same name, domain and path in `.cookies`) + +```typescript +response.clearCookie("session"); +response.clearCookie("__Host-chips", { same_site: "None", partitioned: true }); +await response.send(); +``` + - `.status()` - Set the status code of the response: an integer from 100 to 599 ```typescript @@ -655,7 +663,21 @@ const headers = { await response.status(402).set(headers).send("Payment is required"); ``` -- `.send()` - send data, framed by the final status: a body gets its exact `Content-Length` (also a stale one is replaced), 1xx, 204 and 304 responses have none, and 205 has `Content-Length: 0`. A response without a body (to `HEAD`, or with a 1xx, 204, 205 or 304 status) sends none: a `Readable` is destroyed without being read, and a response to `HEAD` gets the `Content-Length` of the body it would send to `GET`. It resolves once the response is flushed to the connection, and rejects when it cannot be sent (e.g. the client went away), or when a `Readable` fails, closes before its end, or does not have its `Content-Length`. Nothing sent yet, such an error can still be replied; otherwise the response is cut, so that the client knows that it is truncated. +- `.type()` - set the `Content-Type`: a media type with its parameters (a `TypeError` otherwise) + +- `.attachment()` - ask the client to save the response (`Content-Disposition: attachment`), with the name to save it as: `filename` in ASCII (other characters are replaced with `_`) and `filename*` in UTF-8 ([RFC 6266](https://www.rfc-editor.org/rfc/rfc6266.html), [RFC 8187](https://www.rfc-editor.org/rfc/rfc8187.html)) + +- `.vary()` - add the names of the request headers that select the response to `Vary` (once each, after the values it has; `*` replaces them all) + +```typescript +await response + .type("text/csv; charset=utf-8") + .attachment("отчёт.csv") + .vary("Accept-Language") + .send("a,b\n1,2\n"); +``` + +- `.send()` - send data, framed by the final status: a body gets its exact `Content-Length` (also a stale one is replaced), 1xx, 204 and 304 responses have none, and 205 has `Content-Length: 0`. A response without a body (to `HEAD`, or with a 1xx, 204, 205 or 304 status) sends none: a `Readable` is destroyed without being read, and a response to `HEAD` gets the `Content-Length` of the body it would send to `GET`. It resolves once the response is flushed to the connection, and rejects when it cannot be sent (e.g. the client went away), or when a `Readable` fails, closes before its end, or does not have its `Content-Length`. Nothing sent yet, such an error can still be replied; otherwise the response is cut, so that the client knows that it is truncated. An unknown `encoding` of a string rejects with a `TypeError` before anything is sent. ```typescript await response.send( @@ -663,14 +685,14 @@ await response.send( ); ``` -- `.json()` - send an object as `application/json` using `JSON.stringify()` +- `.json()` - send a JSON value (`null`, a number, a string, a boolean, an array or an object) as `application/json` using `JSON.stringify()`. A value without a JSON text (`undefined`, a function or a symbol), a `bigint` or a cyclic value rejects with a `TypeError` before the response is started, so that the error can still be replied ```typescript const json = { k: "v", k1: 1, m: "message", f: false }; await response.json(json); ``` -or using a custom `stringify` function +or using a custom `stringify` function, which must return a string ```typescript const json = { currency: "💶", value: 120 }; @@ -692,14 +714,14 @@ const stringify = fastJSON({ await response.json(json, stringify); ``` -- `.text()` - send text as `text/plain` +- `.text()` - send text as `text/plain; charset=utf-8` ```typescript const text = "Hello World!"; await response.text(text); ``` -- `.html()` - send text as `text/html` +- `.html()` - send text as `text/html; charset=utf-8` ```typescript const html = ""; @@ -717,22 +739,79 @@ const form = new URLSearchParams([ await response.form(form); ``` -- `.sendFile()` - Send a file with its `Last-Modified` (never in the future). Its metadata and its content are read from the one file it opens (a FIFO is not waited for), and no more than its size is sent. Follows [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html) for the following headers, with the `ETag` set on the response (if any), in this order +- `.redirect()` - redirect (`302 Found` by default, or `301`, `303`, `307` and `308`) with an empty body. `Location` is the [WHATWG URL](https://url.spec.whatwg.org/) serialization of the location, resolved against the URL of the request (a relative reference stays relative). Without `allow_external`, only a relative reference or an absolute URL of the origin of the request (`BindenRequest.URL`; none is known for other requests) is allowed, so that a location taken from the request cannot send the client to another site; a network-path reference (`//host/path`) needs `allow_external` too. A location with userinfo, a control character, whitespace, a backslash or a scheme other than `http` and `https` is never allowed. A refused location rejects with a `TypeError` before the response is started. + +```typescript +await response.redirect("/login?next=%2Faccount", 303); +// To another site +await response.redirect("https://example.com/", 302, { allow_external: true }); +``` + +- `.flush()` - send the headers, then resolve once what has been written so far is flushed to the connection (e.g. an event of a `text/event-stream`); it rejects when the response is closed before. [@binden/compression](https://github.com/binden-js/binden/tree/main/packages/compression) flushes its compressor as well. + +```typescript +response.setHeader("Content-Type", "text/event-stream"); +response.write("data: 1\n\n"); +await response.flush(); +``` + +- Dynamic `ETag` - with the `etag` option of the response (`false` by default), a `200` response to `GET` or `HEAD` whose body is a `Buffer` or a string (`.send()`, `.json()`, `.text()`, `.html()`, `.form()`) gets a strong `ETag` (the SHA-256 of the bytes that are sent, in base64url), unless it has an `ETag` already, and becomes `304 Not Modified` (without a body, with the same `ETag`, `Vary` and `Cache-Control`) when `If-None-Match` matches it by the weak comparison. With `http.createServer()`, set the option in a subclass + +```typescript +import { createServer } from "http"; +import { + BindenResponse, + type IResponseOptions, + validateResponseOptions, +} from "binden"; + +class ETagResponse extends BindenResponse { + protected override get options(): Readonly { + return validateResponseOptions({ etag: true }); + } +} +const server = createServer({ ServerResponse: ETagResponse }); +``` + +- `.sendFile()` - Send a file with its `Last-Modified` (never in the future), a weak `ETag` from its size and modification time (`W/"-"`, unless the response has an `ETag`), its `Content-Type` (by the extension of its name, from a table of common web types, otherwise `application/octet-stream`, unless the response has one), `Accept-Ranges: bytes` and `X-Content-Type-Options: nosniff`. Its metadata and its content are read from the one file it opens (a FIFO is not waited for), and no more than its size is sent. Follows [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html) for the following headers, with the `ETag` of the response, in this order - [If-Match](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match) (or else [If-Unmodified-Since](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Unmodified-Since)) - `412 Precondition Failed` when it does not match (by the strong comparison; `*` matches the file) - [If-None-Match](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match) (or else [If-Modified-Since](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since), for `GET` and `HEAD`) - when it matches (by the weak comparison), `304 Not Modified` for `GET` and `HEAD`, `412 Precondition Failed` for other methods - - [Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range) - for `GET` only: `206 Partial Content` for one range (overlapping or adjacent ranges are merged; several distinct ranges get the whole file), `416 Range Not Satisfiable` when no range can be satisfied + - [Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range) - for `GET` only: `206 Partial Content` for one range (overlapping or adjacent ranges are merged; several distinct ranges get the whole file, as `multipart/byteranges` is not supported), `416 Range Not Satisfiable` when no range can be satisfied - - [If-Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range) - the ranges apply when it is the strong `ETag` set on the response, or exactly the `Last-Modified` when that is at least a second old (a strong validator) + - [If-Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range) - the ranges apply when it is a strong `ETag` of the response (a weak one gets the whole file), or exactly the `Last-Modified` when that is at least a second old (a strong validator) A response to `HEAD` gets the headers of the whole file, without reading it. + The path is a filesystem path (not URL-decoded) or a `file:` URL. Without `root`, it is trusted as it is: a missing file rejects with the error of the filesystem. + ```typescript const path = ""; await response.sendFile(path); ``` +With the `root` option, the path must be a relative string (a `URL` throws a `TypeError`) that is served from inside the root directory (e.g. a parameter of the request): a path that is absolute or has an empty, `.` or `..` segment (or a NUL character, and on Windows a drive letter, a stream or a UNC path; the segments are split on `/`, and on Windows also on `\`), or a file that does not exist, is not a regular file, or whose real path (with every symbolic link or junction resolved) is not inside the real path of the root rejects with a `404` `BindenError`. A segment that starts with a dot, in the path or in its real path, follows `dotfiles`: `"ignore"` (`404`, the default), `"deny"` (`403`) or `"allow"`. The file is opened without following a symbolic link that replaced it since its real path was resolved. + +The guarantee has limits: the root directory (and the directories above it) must be trusted, and a race in which an attacker who can write inside the root replaces a directory of the path by a symbolic link between the check and the open is not prevented. + +```typescript +await response.sendFile("css/site.css", { + root: new URL("./public/", import.meta.url), + dotfiles: "deny", + max_age: 31536000, + immutable: true, +}); +``` + +The other options (an invalid one rejects with a `TypeError` or a `RangeError` before the file is opened): + +- `etag` - the weak `ETag` from the size and the modification time (`true` by default) +- `validator` - a strong `ETag` (`"…"`) that the application guarantees for the file (e.g. a hash of its content), sent instead +- `last_modified` - the `Last-Modified` header (`true` by default); without it, `If-Modified-Since`, `If-Unmodified-Since` and a date in `If-Range` do not apply +- `cache_control` - the `Cache-Control` header, or else `max_age` (in seconds: `public, max-age=`) with `immutable` (which needs `max_age`) +- `content_type` - the `Content-Type` (a media type), or `false` to leave it unset + ### Headers - [Accept](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept) - `Accept.fromString()` returns the valid elements (media ranges in lowercase, with their parameters and weight) from the greatest weight, and `Accept.negotiate()` selects one of the offered media types like `request.accepts()` @@ -835,7 +914,7 @@ const type = ContentType.fromString(request.headers["content-type"]); const { content_type } = request; ``` -- [Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie) - `new Cookie()` throws a `TypeError` for a name, value or attribute that would change the `Set-Cookie` header (e.g. a value with `;`); `Cookie.fromString()` skips such cookies +- [Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie) - `new Cookie()` throws a `TypeError` for a name, value or attribute that would change the `Set-Cookie` header (e.g. a value with `;`); `Cookie.fromString()` skips such cookies. A cookie is `Secure` with the `__Secure-` or `__Host-` prefix, `SameSite=None`, or `partitioned` ([CHIPS](https://developer.mozilla.org/en-US/docs/Web/Privacy/Guides/Privacy_sandbox/Partitioned_cookies), the `Partitioned` attribute) ```typescript import { Cookie } from "binden"; From 2659e842e41b2216ae7a0633f784d0346e3fd5ca Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:30:19 +0300 Subject: [PATCH 133/675] test: remove a race from the weak Last-Modified test in binden The test wrote a file and expected its Last-Modified to be weak (less than a second old) when its request was handled. A file written late in a second is a second old once its request is handled in the next one, which happens under load: the If-Range then matched, and the test got 206. The file is now modified in the future, so that its Last-Modified is the current second. Co-Authored-By: Claude Opus 5.5 --- packages/binden/test/response.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/binden/test/response.test.ts b/packages/binden/test/response.test.ts index 486b7de2..aae1a062 100644 --- a/packages/binden/test/response.test.ts +++ b/packages/binden/test/response.test.ts @@ -1298,11 +1298,15 @@ suite("BindenResponse", () => { [200, null, whole], ); - // A file modified within the last second has a weak `Last-Modified` + // A `Last-Modified` within the last second is weak: the one of a file + // modified in the future is now. (Not a file written now: one written + // late in a second is a second old once its request is handled in the + // next one.) const recent = join(root, "recent"); await writeFile(recent, msg); - const { mtime } = await statOf(recent); - const recentLm = new Date(Math.floor(mtime.getTime() / 1000) * 1000); + const future = new Date(Date.now() + 3600 * 1000); + await utimes(recent, future, future); + const recentLm = new Date(Math.floor(Date.now() / 1000) * 1000); deepStrictEqual( await send( { ...range, "If-Range": recentLm.toUTCString() }, From 73882fa7893d6fa6368f692b7c6c921ed1c936e2 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:44:14 +0300 Subject: [PATCH 134/675] build: publish the archives that the release packs and checks `scripts/release.mjs publish` ran `npm publish --workspace` for each package: every package was packed again at publication, its content was never checked, and a recovery (`--skip-existing`) only compared the `gitHead` of the packages already published, so a different archive of the same commit went unnoticed. The release is now made of archives packed once: - `version ` also writes the commit of HEAD as `gitHead` in every workspace; - `pack

` packs every workspace once into an empty directory, refuses changes that are not committed, checks the name, the version, the `gitHead` and the files (`scripts/pack-files.json`) inside each archive, and writes `release-manifest.json` (name, version, commit, `sha512` integrity and file of each archive) in dependency order; - `publish ` checks the integrity and the content of these archives, then queries the registry for all the packages before the first publication (only an E404 means absent), and publishes nothing on a registry error or when a version is published from another archive (`gitHead` or `dist.integrity`). Then it runs `npm publish ` in dependency order. `--skip-existing` skips only the versions published from the same archive, and `--dry-run` does everything but the upload. Versions follow the SemVer 2.0.0 grammar strictly (no leading zeros, no empty identifiers), without build metadata, which npm drops. The archives are read without a `tar` command (`scripts/tarballs.mjs`), and the tests use a fake npm that packs, publishes and answers from a fake registry, including a recovery after a failure at each package. semantic-release packs in its prepare step (before the tag is pushed) and publishes the same archives, and the `recover` job packs again from the commit of the tag. The release rehearsal runs `verify`, `version`, `pack` and `publish --dry-run` in its clone (and packs the current version when no release is due). `scripts/pack-files.json` lists the module that shares the state of a request, which the packed files missed. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 30 +- .releaserc.json | 4 +- README.md | 56 ++- package.json | 2 +- scripts/fixtures/fake-npm.mjs | 115 +++++ scripts/fixtures/tar.mjs | 65 +++ scripts/pack-files.json | 2 + scripts/release-rehearsal.mjs | 23 +- scripts/release.mjs | 373 ++++++++++++--- scripts/release.test.mjs | 857 +++++++++++++++++++++++++++------- scripts/tarballs.mjs | 169 +++++++ scripts/tarballs.test.mjs | 265 +++++++++++ 12 files changed, 1688 insertions(+), 273 deletions(-) create mode 100644 scripts/fixtures/fake-npm.mjs create mode 100644 scripts/fixtures/tar.mjs create mode 100644 scripts/tarballs.mjs create mode 100644 scripts/tarballs.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05a091e3..56b7c256 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: - name: ESLint run: npm run eslint - - name: Tooling tests (release script, test runner) + - name: Tooling tests (release scripts, test runner) run: npm run test:tooling - name: Packed files (scripts/pack-files.json) @@ -67,8 +67,9 @@ jobs: - name: Consumer check (packed packages, TypeScript and ESM) run: npm run consumer:check - # The next version and its release notes, as semantic-release computes them (nothing is - # tagged or published) + # The next version, its release notes and its archives, made as the release job makes + # them (scripts/release.mjs `verify`, `version`, `pack` and `publish --dry-run`): + # nothing is tagged or published - name: Release rehearsal run: npm run release:rehearsal @@ -139,7 +140,7 @@ jobs: - name: Tests run: npm test - - name: Tooling tests (release script, test runner) + - name: Tooling tests (release scripts, test runner) run: npm run test:tooling release: @@ -175,21 +176,23 @@ jobs: run: npm ci # Tags the release, publishes every workspace with the same version (see - # scripts/release.mjs) and creates the GitHub Release, whose notes are the changelog. - # Nothing is committed to `main`, so its branch protection applies as usual. - # npm uses trusted publishing (OIDC) when it is configured for a package and - # falls back to NODE_AUTH_TOKEN otherwise. + # scripts/release.mjs: `version`, `pack` into RELEASE_DIR, then `publish` of these + # archives once the registry is checked for all of them) and creates the GitHub Release, + # whose notes are the changelog. Nothing is committed to `main`, so its branch + # protection applies as usual. npm uses trusted publishing (OIDC) when it is configured + # for a package and falls back to NODE_AUTH_TOKEN otherwise. - name: Release run: npx semantic-release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: true + RELEASE_DIR: ${{ runner.temp }}/release # Publishes the packages of a release that are not published yet, from the commit of its - # tag (`scripts/release.mjs` checks that the published ones come from it), and creates its - # GitHub Release when it is missing. Run from the Actions tab (in this workflow, which npm - # trusts for publishing). + # tag (`scripts/release.mjs` checks that the published ones are the same archives), and + # creates its GitHub Release when it is missing. Run from the Actions tab (in this + # workflow, which npm trusts for publishing). recover: if: github.event_name == 'workflow_dispatch' @@ -220,10 +223,13 @@ jobs: - name: Install dependencies and build the workspaces run: npm ci + # Packs the archives again, checks the registry for all of them, then publishes the + # missing ones with provenance - name: Publish the packages that are not published yet run: | node scripts/release.mjs version "$VERSION" - node scripts/release.mjs publish --skip-existing + node scripts/release.mjs pack "$RUNNER_TEMP/release" + node scripts/release.mjs publish "$RUNNER_TEMP/release" --skip-existing env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: true diff --git a/.releaserc.json b/.releaserc.json index 13934d1f..8cbe0eec 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -139,8 +139,8 @@ "@semantic-release/exec", { "verifyReleaseCmd": "node scripts/release.mjs verify ${nextRelease.version}", - "prepareCmd": "node scripts/release.mjs version ${nextRelease.version}", - "publishCmd": "node scripts/release.mjs publish" + "prepareCmd": "node scripts/release.mjs version ${nextRelease.version} && node scripts/release.mjs pack \"$RELEASE_DIR\"", + "publishCmd": "node scripts/release.mjs publish \"$RELEASE_DIR\"" } ], "@semantic-release/github" diff --git a/README.md b/README.md index 3f9b6415..dfe23cf7 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ npm run coverage # tests with c8 coverage (coverage/lcov.info) npm run eslint # type-aware ESLint for the whole repository npm run docs:build # TypeDoc for all packages (docs/) npm run consumer:check # the packed packages and the README examples in a fresh project -npm run release:rehearsal # the next version and its release notes, without releasing +npm run release:rehearsal # the next version, its notes and its archives, without releasing npm run ci:all # everything CI runs ``` @@ -73,43 +73,63 @@ They do not affect the published packages. ## Releases Releases are fully automated by [semantic-release](https://github.com/semantic-release/semantic-release) -on every push to `main` (see [`.releaserc.json`](.releaserc.json)): +on every push to `main` (see [`.releaserc.json`](.releaserc.json)), in the `release` job of the CI +workflow: 1. the next version is derived from [Conventional Commits](https://conventionalcommits.org) since - the last `v*` tag, and [`scripts/release.mjs`](scripts/release.mjs) checks that no workspace is - published at it yet; -2. `scripts/release.mjs` sets that version in **every** workspace (and the internal dependency - ranges to `^`); -3. the `v` tag is pushed; -4. `scripts/release.mjs` publishes all packages to npm in dependency order; -5. a [GitHub Release](https://github.com/binden-js/binden/releases) with the release notes is + the last `v*` tag, and [`scripts/release.mjs`](scripts/release.mjs) `verify` checks that no + workspace is published at it yet (only a 404 of the registry means "not published": any other + error stops the release); +2. `scripts/release.mjs version` sets that version in **every** workspace (and the internal + dependency ranges to `^`), with the commit as `gitHead`; +3. `scripts/release.mjs pack` packs every workspace once, checks the name, the version, the + `gitHead` and the files ([`scripts/pack-files.json`](scripts/pack-files.json)) inside each + archive, and lists the archives (name, version, commit, `sha512` integrity, file) in + `release-manifest.json`; +4. the `v` tag is pushed; +5. `scripts/release.mjs publish` checks the integrity and the content of the archives, then the + registry for **all** the packages, and only then publishes these archives + (`npm publish `, with provenance) in dependency order; +6. a [GitHub Release](https://github.com/binden-js/binden/releases) with the release notes is created: they are the changelog (see [`CHANGELOG.md`](CHANGELOG.md)). Nothing is committed back to `main`, as [recommended by semantic-release](https://semantic-release.gitbook.io/semantic-release/support/faq): a release commit would have to bypass the branch protection of `main`. The versions in the repository therefore stay at `0.0.0-development` (and the internal ranges at `^0.0.0-development`); -the published version is set only in the checkout of the release job. +the published version is set only in the checkout of the release job. Versions are strict +[SemVer 2.0.0](https://semver.org) without build metadata (which npm drops). -`npm run release:check` verifies that all workspaces share one version. +`npm run release:check` verifies that all workspaces share one version. Every pull request rehearses +the release (`npm run release:rehearsal`): semantic-release computes the next version and its notes +in a clone, where `scripts/release.mjs` runs `verify`, `version`, `pack` and `publish --dry-run` +(the registry check and `npm publish --dry-run` of every archive). Nothing is tagged or published. ### Recovering a release When a release fails after its tag was pushed (for example, some packages were published, but not all of them), the next release would start after that tag: recover it instead, by running the CI workflow manually (Actions → CI → Run workflow) with its version. The `recover` job checks out -the commit of the tag, publishes the packages that are not published yet (with provenance), and -creates the GitHub Release when it is missing (with notes generated by GitHub). It refuses to -publish from another commit than the one of the tag, and when an already published package was -published from another commit (its `gitHead` on npm). +the commit of the tag, and: -The same can be done by hand from a checkout of the tag (packages published from outside CI have no -provenance): +1. packs the archives again with `scripts/release.mjs version` and `pack` (the same archives as the + release job); +2. publishes with `scripts/release.mjs publish --skip-existing`: the registry is checked for all the + packages first, a version that is already published is skipped only when it is the same archive + (its `gitHead` and `dist.integrity` on npm), and any other difference, or any registry error + other than a 404, publishes nothing. The missing packages are published with provenance; +3. creates the GitHub Release when it is missing, with notes generated by GitHub. + +The same can be done by hand from a checkout of the tag (packages published from outside of CI have +no provenance). With another runtime than the one of the release job, the archives may differ from +the published ones, and `publish` then refuses to publish anything: ```bash git checkout v +npm ci node scripts/release.mjs version -node scripts/release.mjs publish --skip-existing +node scripts/release.mjs pack +node scripts/release.mjs publish --skip-existing ``` The `CHANGELOG.md` files inside `packages/*` describe the history before the packages were merged diff --git a/package.json b/package.json index a2231ce6..beed1749 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "release:check": "node scripts/release.mjs check", "release:rehearsal": "node scripts/release-rehearsal.mjs", "test": "npm test --workspaces --if-present --", - "test:tooling": "node --test scripts/*.test.mjs" + "test:tooling": "node --test --test-timeout=120000 scripts/*.test.mjs" }, "repository": { "type": "git", diff --git a/scripts/fixtures/fake-npm.mjs b/scripts/fixtures/fake-npm.mjs new file mode 100644 index 00000000..021a6af0 --- /dev/null +++ b/scripts/fixtures/fake-npm.mjs @@ -0,0 +1,115 @@ +// A fake npm CLI for the tests of the release tooling, run as `npm_execpath` (see +// scripts/npm.mjs) from the root of a fixture repository. Every call is appended to +// FAKE_NPM_LOG (a JSON array of its arguments per line). +// +// view @ --json +// the document of FAKE_NPM_REGISTRY (a JSON file: `name@version` -> document), +// E404 otherwise, or the error FAKE_NPM_VIEW_ERROR (for FAKE_NPM_VIEW_ERROR_SPEC +// only, when set; `crash` fails without a JSON error) +// publish [--dry-run] +// adds the archive to the registry (its `gitHead` and its integrity), but fails +// (E500) for the package FAKE_NPM_FAIL_PUBLISH +// pack --workspaces --pack-destination +// packs every file of every workspace inside `package/`, like npm, with the +// package.json fields of FAKE_NPM_PACK_PATCH ({ "": { ...fields } }); +// fails when FAKE_NPM_FAIL_PACK is set +// install +// rewrites package-lock.json +// +// Every other command succeeds without doing anything. +import { + appendFileSync, + existsSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; +import { inspectTarball, integrity } from "../tarballs.mjs"; +import { join, sep } from "node:path"; +import { npmTarball } from "./tar.mjs"; + +const args = process.argv.slice(2); +const { env } = process; +appendFileSync(env.FAKE_NPM_LOG, `${JSON.stringify(args)}\n`); + +const readJSON = (file) => JSON.parse(readFileSync(file, "utf8")); +function registry() { + return existsSync(env.FAKE_NPM_REGISTRY) + ? readJSON(env.FAKE_NPM_REGISTRY) + : {}; +} + +function fail(code) { + process.stdout.write(JSON.stringify({ error: { code } })); + process.stderr.write(`npm error code ${code}\n`); + process.exit(1); +} + +function view([spec]) { + const error = env.FAKE_NPM_VIEW_ERROR; + if ( + error && + (!env.FAKE_NPM_VIEW_ERROR_SPEC || env.FAKE_NPM_VIEW_ERROR_SPEC === spec) + ) { + if (error === "crash") { + process.stderr.write("npm error\n"); + process.exit(1); + } + fail(error); + } + const document = registry()[spec]; + if (typeof document === "undefined") { + fail("E404"); + } + process.stdout.write(JSON.stringify(document)); +} + +function publish([file, ...options]) { + const data = readFileSync(file); + const { manifest } = inspectTarball(data); + if (manifest.name === env.FAKE_NPM_FAIL_PUBLISH) { + fail("E500"); + } + if (!options.includes("--dry-run")) { + const documents = registry(); + documents[`${manifest.name}@${manifest.version}`] = { + name: manifest.name, + version: manifest.version, + gitHead: manifest.gitHead, + dist: { integrity: integrity(data) }, + }; + writeFileSync(env.FAKE_NPM_REGISTRY, JSON.stringify(documents)); + } +} + +function pack(options) { + if (env.FAKE_NPM_FAIL_PACK) { + fail("EPACK"); + } + const destination = options[options.indexOf("--pack-destination") + 1]; + const patches = JSON.parse(env.FAKE_NPM_PACK_PATCH ?? "{}"); + for (const dir of readJSON("package.json").workspaces) { + const files = {}; + for (const path of readdirSync(dir, { recursive: true })) { + if (statSync(join(dir, path)).isFile()) { + files[path.split(sep).join("/")] = readFileSync(join(dir, path)); + } + } + const manifest = JSON.parse(files["package.json"]); + files["package.json"] = JSON.stringify({ ...manifest, ...patches[dir] }); + const file = `${manifest.name.replace("@", "").replace("/", "-")}-${manifest.version}.tgz`; + writeFileSync(join(destination, file), npmTarball(files)); + } +} + +const [command, ...rest] = args; +if (command === "view") { + view(rest); +} else if (command === "publish") { + publish(rest); +} else if (command === "pack") { + pack(rest); +} else if (command === "install") { + writeFileSync("package-lock.json", `${JSON.stringify({ args })}\n`); +} diff --git a/scripts/fixtures/tar.mjs b/scripts/fixtures/tar.mjs new file mode 100644 index 00000000..8efd281d --- /dev/null +++ b/scripts/fixtures/tar.mjs @@ -0,0 +1,65 @@ +// Writes gzipped tar archives for the tests of the release tooling, entry by entry +// (ustar headers), so that the tests control what an archive contains. +import { gzipSync } from "node:zlib"; + +const blockSize = 512; + +function pad(data) { + const rest = data.length % blockSize; + return rest === 0 + ? data + : Buffer.concat([data, Buffer.alloc(blockSize - rest)]); +} + +function header({ name, size, type, prefix }) { + const block = Buffer.alloc(blockSize); + block.write(name, 0, 100, "utf8"); + block.write("0000644\0", 100); + block.write("0000000\0", 108); + block.write("0000000\0", 116); + block.write(`${size.toString(8).padStart(11, "0")}\0`, 124); + block.write("00000000000\0", 136); + block.write(" ".repeat(8), 148); + block.write(type, 156); + block.write("ustar\0", 257); + block.write("00", 263); + block.write(prefix, 345, 155, "utf8"); + const checksum = block.reduce((sum, byte) => sum + byte, 0); + block.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148); + return block; +} + +/** A pax extended header record (` =\n`) */ +export function paxRecord(key, value) { + const record = ` ${key}=${value}\n`; + let length = Buffer.byteLength(record); + length += String(length + String(length).length).length; + return `${length}${record}`; +} + +/** + * A gzipped tar archive of `entries` (`{ name, content, type, prefix, size }`, by + * default a regular file), in their order + */ +export function tar(entries) { + const blocks = entries.flatMap( + ({ name, content = "", type = "0", prefix = "", size }) => { + const data = Buffer.from(content); + return [ + header({ name, size: size ?? data.length, type, prefix }), + pad(data), + ]; + }, + ); + return gzipSync(Buffer.concat([...blocks, Buffer.alloc(blockSize * 2)])); +} + +/** An npm tarball: every file of `files` ({ path: content }) inside `package/` */ +export function npmTarball(files) { + return tar( + Object.entries(files).map(([path, content]) => ({ + name: `package/${path}`, + content, + })), + ); +} diff --git a/scripts/pack-files.json b/scripts/pack-files.json index 02c3738c..77ef40a7 100644 --- a/scripts/pack-files.json +++ b/scripts/pack-files.json @@ -17,6 +17,8 @@ "dist/src/context.js", "dist/src/error.d.ts", "dist/src/error.js", + "dist/src/exchange.d.ts", + "dist/src/exchange.js", "dist/src/headers/accept-encoding.d.ts", "dist/src/headers/accept-encoding.js", "dist/src/headers/authorization.d.ts", diff --git a/scripts/release-rehearsal.mjs b/scripts/release-rehearsal.mjs index 75835f59..26e89c80 100644 --- a/scripts/release-rehearsal.mjs +++ b/scripts/release-rehearsal.mjs @@ -2,8 +2,11 @@ // Rehearses the release of the current commit, without tagging or publishing // anything: semantic-release (with its analyzer and its notes only) computes the // next version in a clone whose remote is a local mirror, then -// `scripts/release.mjs` checks that no workspace is published at it yet, and -// sets it in every workspace of the clone. +// `scripts/release.mjs` runs as the release job does in the clone: `verify` (no +// workspace is published at that version yet), `version`, `pack` (the archives and +// their manifest), and `publish --dry-run` (the registry check, then `npm publish +// --dry-run` of every archive). When no release is due, the current version is +// packed. import { execFileSync, spawnSync } from "node:child_process"; import { mkdtempSync, @@ -20,6 +23,7 @@ const root = join(import.meta.dirname, ".."); const dir = mkdtempSync(join(tmpdir(), "binden-rehearsal-")); const mirror = join(dir, "mirror.git"); const work = join(dir, "work"); +const archives = join(dir, "archives"); const plugins = new Set([ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", @@ -86,13 +90,26 @@ try { throw new Error(`semantic-release failed (${status})`); } + // `pack` packs committed files only + git(["checkout", "--quiet", "--", ".releaserc.json"]); + const version = /The next release version is (?\S+)/u.exec(stdout) ?.groups?.version; if (typeof version === "undefined") { - log("no release is due"); + const { workspaces } = JSON.parse( + readFileSync(join(work, "package.json"), "utf8"), + ); + const { version: current } = JSON.parse( + readFileSync(join(work, workspaces[0], "package.json"), "utf8"), + ); + log(`no release is due: packing the current version (${current})`); + node(["scripts/release.mjs", "version", current]); + node(["scripts/release.mjs", "pack", archives]); } else { node(["scripts/release.mjs", "verify", version]); node(["scripts/release.mjs", "version", version]); + node(["scripts/release.mjs", "pack", archives]); + node(["scripts/release.mjs", "publish", archives, "--dry-run"]); log(`the next release is ${version}`); } } finally { diff --git a/scripts/release.mjs b/scripts/release.mjs index 4d8ab629..02c4c5ab 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -3,19 +3,32 @@ // // check all workspaces share one version and pin each other to `^` // verify none of the workspaces is published at yet -// version set everywhere and refresh package-lock.json -// publish publish every workspace in the `workspaces` order -// (`--skip-existing` skips already published ones, to recover a release -// that failed half-way: from the commit of its tag only, and only when the -// published ones come from that commit too) +// version set and `gitHead` (the commit of HEAD) in every workspace, +// and refresh package-lock.json +// pack pack every workspace once into (missing or empty), check the +// name, the version, the `gitHead` and the files (scripts/pack-files.json) +// inside each archive, and list them in /release-manifest.json +// publish publish the archives of /release-manifest.json in dependency order, +// once the registry has been checked for all of them +// --skip-existing skip the versions already published from the same archive (`gitHead` +// and `dist.integrity`), to recover a release that failed half-way: from +// the commit of its tag only +// --dry-run everything but the upload (`npm publish --dry-run`) // -// Provenance is enabled by the CI release job (NPM_CONFIG_PROVENANCE), so a manual -// `publish --skip-existing` works outside of CI too. +// Versions follow SemVer 2.0.0 strictly. Provenance is enabled by the CI release job +// (NPM_CONFIG_PROVENANCE), so a manual `publish --skip-existing` works outside of CI too. // Everything is logged to stderr: @semantic-release/exec parses the stdout of `publishCmd`. -import { readFileSync, writeFileSync } from "node:fs"; +import { + compareFiles, + inspectTarball, + integrity, + manifestFile, + readRelease, +} from "./tarballs.mjs"; +import { join, resolve } from "node:path"; +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { npm as execNpm } from "./npm.mjs"; -import { join } from "node:path"; const root = join(import.meta.dirname, ".."); const dependencyFields = [ @@ -24,6 +37,16 @@ const dependencyFields = [ "optionalDependencies", "peerDependencies", ]; +// The dependencies that a consumer installs with a package: publication order +const runtimeFields = [ + "dependencies", + "optionalDependencies", + "peerDependencies", +]; +// SemVer 2.0.0 (https://semver.org/#backusnaur-form-grammar-for-valid-semver-versions): +// no leading zeros in numeric identifiers, non-empty identifiers, ASCII only +const semver = + /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][\dA-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][\dA-Za-z-]*))*)?(?:\+[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?$/u; function readJSON(file) { return JSON.parse(readFileSync(file, "utf8")); @@ -37,6 +60,10 @@ function npm(args, options = {}) { return execNpm(args, { cwd: root, encoding: "utf8", ...options }); } +function git(args) { + return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); +} + function workspaces() { return readJSON(join(root, "package.json")).workspaces.map((dir) => { const file = join(root, dir, "package.json"); @@ -44,6 +71,45 @@ function workspaces() { }); } +function validVersion(version) { + if (!semver.test(version)) { + throw new Error(`Invalid version: ${version}`); + } + // npm drops it (`1.0.0+a` would be published as `1.0.0`, different from its tag) + if (version.includes("+")) { + throw new Error(`Build metadata is not supported by npm: ${version}`); + } + return version; +} + +// The workspaces in dependency order (a package after the packages that it needs), +// otherwise in the `workspaces` order +function dependencyOrder(packages) { + const names = new Set(packages.map(({ manifest }) => manifest.name)); + const ordered = []; + const done = new Set(); + const needs = ({ manifest }) => + runtimeFields.flatMap((field) => + Object.keys(manifest[field] ?? {}).filter( + (name) => names.has(name) && name !== manifest.name, + ), + ); + while (ordered.length < packages.length) { + const next = packages.find( + (pkg) => + !done.has(pkg.manifest.name) && + needs(pkg).every((name) => done.has(name)), + ); + if (typeof next === "undefined") { + const rest = [...names].filter((name) => !done.has(name)); + throw new Error(`Circular dependencies between ${rest.join(", ")}`); + } + ordered.push(next); + done.add(next.manifest.name); + } + return ordered; +} + function errorCode(stdout) { try { return JSON.parse(stdout).error?.code ?? null; @@ -52,17 +118,19 @@ function errorCode(stdout) { } } -// Only a confirmed "not found" (E404) means "not published": registry, network or -// authentication errors must stop the release before anything is tagged or published. -function isPublished(name, version) { +// The `gitHead` and the `dist.integrity` of a published version, or null when the +// registry confirms that it does not exist (E404, with `--json` on stdout). Registry, +// network or authentication errors stop the release before anything is published. +function published(name, version) { try { - const stdout = npm(["view", `${name}@${version}`, "version", "--json"], { + const stdout = npm(["view", `${name}@${version}`, "--json"], { stdio: ["ignore", "pipe", "pipe"], }); - return stdout.trim() !== ""; + const { gitHead = null, dist } = JSON.parse(stdout); + return { gitHead, integrity: dist?.integrity ?? null }; } catch (error) { if (errorCode(error.stdout) === "E404") { - return false; + return null; } throw error; } @@ -93,23 +161,27 @@ function check() { ); } log(`${packages.length} workspaces in lockstep at ${first.version}`); + return { packages, version: first.version }; } function verify(version) { - const published = workspaces() + const existing = workspaces() .map(({ manifest }) => manifest.name) - .filter((name) => isPublished(name, version)); - if (published.length > 0) { - throw new Error(`Already published at ${version}: ${published.join(", ")}`); + .filter((name) => published(name, version) !== null); + if (existing.length > 0) { + throw new Error(`Already published at ${version}: ${existing.join(", ")}`); } log(`no workspace is published at ${version} yet`); } function setVersion(version) { + const head = git(["rev-parse", "HEAD"]); const packages = workspaces(); const names = new Set(packages.map(({ manifest }) => manifest.name)); for (const { file, manifest } of packages) { manifest.version = version; + // The commit that the archives are packed from (npm publishes it as `gitHead`) + manifest.gitHead = head; for (const field of dependencyFields) { for (const name of Object.keys(manifest[field] ?? {})) { if (names.has(name)) { @@ -118,77 +190,264 @@ function setVersion(version) { } } writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`); - log(`${manifest.name} -> ${version}`); + log(`${manifest.name} -> ${version} (${head})`); } - npm(["install", "--package-lock-only", "--ignore-scripts"], { - stdio: ["ignore", process.stderr, "inherit"], - }); + npm( + [ + "install", + "--package-lock-only", + "--ignore-scripts", + "--no-audit", + "--no-fund", + ], + { stdio: ["ignore", process.stderr, "inherit"] }, + ); check(); } -function git(args) { - return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); +// The archives are packed from the commit that their `gitHead` names: only the files +// that `version` changes may differ from it +function assertCommitted(packages) { + const allowed = new Set([ + "package-lock.json", + ...packages.map(({ dir }) => `${dir}/package.json`), + ]); + const changed = git(["diff", "--name-only", "HEAD"]) + .split("\n") + .filter((path) => path !== "" && !allowed.has(path)); + if (changed.length > 0) { + throw new Error(`Uncommitted changes: ${changed.join(", ")}`); + } } -// The commit that a published version was published from (npm records it as `gitHead`) -function publishedFrom(name, version) { - const stdout = npm(["view", `${name}@${version}`, "gitHead", "--json"], { - stdio: ["ignore", "pipe", "pipe"], +// The differences between an archive (`inspectTarball`) and the expected package +function archiveErrors(file, { manifest, files }, expected) { + const errors = []; + for (const field of ["name", "version", "gitHead"]) { + if (manifest[field] !== expected[field]) { + errors.push( + `${file}: ${field} ${manifest[field]}, expected ${expected[field]}`, + ); + } + } + const differences = compareFiles( + { [expected.name]: expected.files }, + { [expected.name]: files }, + ); + return [...errors, ...differences.map((error) => `${file}: ${error}`)]; +} + +function expectedFiles() { + return readJSON(join(root, "scripts", "pack-files.json")); +} + +function pack(dir) { + const { packages, version } = check(); + const head = git(["rev-parse", "HEAD"]); + assertCommitted(packages); + mkdirSync(dir, { recursive: true }); + if (readdirSync(dir).length > 0) { + throw new Error(`${dir} is not empty`); + } + npm(["pack", "--workspaces", "--pack-destination", dir, "--loglevel=warn"], { + stdio: ["ignore", process.stderr, "inherit"], }); - return stdout.trim() === "" ? null : JSON.parse(stdout); + + const errors = []; + const archives = new Map(); + for (const file of readdirSync(dir)) { + const data = readFileSync(join(dir, file)); + const archive = { + file, + integrity: integrity(data), + ...inspectTarball(data), + }; + const { name } = archive.manifest; + if (archives.has(name)) { + errors.push(`${file}: another archive of ${name}`); + } + archives.set(name, archive); + } + const allFiles = expectedFiles(); + const ordered = dependencyOrder(packages); + for (const { manifest } of ordered) { + const archive = archives.get(manifest.name); + if (typeof archive === "undefined") { + errors.push(`${manifest.name}: no archive`); + } else { + errors.push( + ...archiveErrors(archive.file, archive, { + name: manifest.name, + version, + gitHead: head, + files: allFiles[manifest.name] ?? [], + }), + ); + } + } + const names = new Set(packages.map(({ manifest }) => manifest.name)); + for (const [name, { file }] of archives) { + if (!names.has(name)) { + errors.push(`${file}: ${name} is not a workspace`); + } + } + if (errors.length > 0) { + throw new Error(`Invalid archives in ${dir}:\n ${errors.join("\n ")}`); + } + + const release = ordered.map(({ manifest: { name } }) => { + const archive = archives.get(name); + return { + name, + version, + sha: head, + integrity: archive.integrity, + file: archive.file, + }; + }); + writeFileSync( + join(dir, manifestFile), + `${JSON.stringify({ packages: release }, null, 2)}\n`, + ); + for (const { name, file, integrity: sum } of release) { + log(`packed ${name}@${version} (${file}, ${sum})`); + } } -function publish(skipExisting) { - check(); - const [{ manifest: first }] = workspaces(); - const head = skipExisting ? git(["rev-parse", "HEAD"]) : null; +// The archives of the manifest, checked against the workspaces, HEAD and their content +function releaseArchives(dir, packages, version, head) { + const entries = new Map(); + const errors = []; + for (const entry of readRelease(dir)) { + if (entries.has(entry.name)) { + errors.push(`${entry.file}: another archive of ${entry.name}`); + } + entries.set(entry.name, entry); + } + const names = new Set(packages.map(({ manifest }) => manifest.name)); + for (const [name, { file }] of entries) { + if (!names.has(name)) { + errors.push(`${file}: ${name} is not a workspace`); + } + } + const allFiles = expectedFiles(); + const ordered = []; + for (const { manifest } of dependencyOrder(packages)) { + const entry = entries.get(manifest.name); + if (typeof entry === "undefined") { + errors.push(`${manifest.name}: not in ${manifestFile}`); + } else { + if (entry.version !== version || entry.sha !== head) { + errors.push( + `${entry.file}: ${entry.version} from ${entry.sha}, expected ${version} from ${head} (HEAD)`, + ); + } + errors.push( + ...archiveErrors(entry.file, inspectTarball(readFileSync(entry.path)), { + name: entry.name, + version: entry.version, + gitHead: entry.sha, + files: allFiles[entry.name] ?? [], + }), + ); + ordered.push(entry); + } + } + if (errors.length > 0) { + throw new Error(`Invalid archives in ${dir}:\n ${errors.join("\n ")}`); + } + return ordered; +} + +function publish(dir, { skipExisting, dryRun }) { + const { packages, version } = check(); + const head = git(["rev-parse", "HEAD"]); if (skipExisting) { // The tag of a release is pushed before its packages are published: the rest is // published from the commit of the tag, not from a later one - const tag = `v${first.version}`; + const tag = `v${version}`; const tagged = git(["rev-parse", "--verify", `${tag}^{commit}`]); if (tagged !== head) { throw new Error(`HEAD (${head}) is not the commit of ${tag} (${tagged})`); } } - for (const { dir, manifest } of workspaces()) { - if (skipExisting && isPublished(manifest.name, manifest.version)) { - const from = publishedFrom(manifest.name, manifest.version); - if (from !== head) { - throw new Error( - `${manifest.name}@${manifest.version} was published from ${from}, not from ${head}`, - ); - } - log(`skip ${manifest.name}@${manifest.version}: already published`); + const archives = releaseArchives(dir, packages, version, head); + + // Every package is checked before the first one is published + const existing = new Map(); + for (const { name } of archives) { + const state = published(name, version); + if (state !== null) { + existing.set(name, state); + } + } + const conflicts = []; + for (const { name, sha, integrity: sum } of archives) { + const state = existing.get(name); + if (typeof state === "undefined") { + log(`${name}@${version} is not published yet`); + } else if (state.gitHead !== sha || state.integrity !== sum) { + conflicts.push( + `${name}@${version} is published from another archive (gitHead ${state.gitHead}, integrity ${state.integrity})`, + ); + } else if (!skipExisting) { + conflicts.push(`${name}@${version} is already published`); + } + } + if (conflicts.length > 0) { + throw new Error(`Nothing is published:\n ${conflicts.join("\n ")}`); + } + + for (const { name, path } of archives) { + if (existing.has(name)) { + log(`skip ${name}@${version}: already published from the same archive`); } else { - log(`publish ${manifest.name}@${manifest.version}`); - npm(["publish", "--workspace", dir], { + log(`publish ${name}@${version}${dryRun ? " (dry run)" : ""}`); + npm(["publish", path, ...(dryRun ? ["--dry-run"] : [])], { stdio: ["ignore", process.stderr, "inherit"], }); } } } -const [command, argument] = process.argv.slice(2); -const versionPattern = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/u; +const [command, ...args] = process.argv.slice(2); +const options = args.filter((arg) => arg.startsWith("--")); +const operands = args.filter((arg) => !arg.startsWith("--")); +const allowedOptions = + command === "publish" ? ["--skip-existing", "--dry-run"] : []; + +for (const option of options) { + if (!allowedOptions.includes(option)) { + throw new Error(`Unknown option: ${option}`); + } +} + +function operand(name) { + const [value = ""] = operands; + if (operands.length !== 1 || value === "") { + throw new Error(`Usage: scripts/release.mjs ${command} <${name}>`); + } + return value; +} switch (command) { case "check": check(); break; case "verify": + verify(validVersion(operand("version"))); + break; case "version": - if (!versionPattern.test(argument ?? "")) { - throw new Error(`Invalid version: ${argument}`); - } - if (command === "verify") { - verify(argument); - } else { - setVersion(argument); - } + setVersion(validVersion(operand("version"))); + break; + case "pack": + pack(resolve(operand("dir"))); break; case "publish": - publish(argument === "--skip-existing"); + publish(resolve(operand("dir")), { + skipExisting: options.includes("--skip-existing"), + dryRun: options.includes("--dry-run"), + }); break; default: throw new Error(`Unknown command: ${command}`); diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs index c8e5dbcb..20a95302 100644 --- a/scripts/release.test.mjs +++ b/scripts/release.test.mjs @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, it } from "node:test"; import { + appendFileSync, copyFileSync, existsSync, mkdirSync, @@ -8,40 +9,14 @@ import { rmSync, writeFileSync, } from "node:fs"; -import { deepEqual, equal, match } from "node:assert/strict"; +import { deepEqual, doesNotMatch, equal, match, ok } from "node:assert/strict"; +import { integrity, manifestFile } from "./tarballs.mjs"; import { join } from "node:path"; +import { npmTarball } from "./fixtures/tar.mjs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; -// A fake npm CLI (run as `npm_execpath`, see scripts/npm.mjs) that logs its -// arguments and emulates `npm view` (E404 unless the package version is listed in -// FAKE_NPM_PUBLISHED, or a network error with FAKE_NPM_OFFLINE), whose `gitHead` -// is FAKE_NPM_GITHEAD, and `npm publish`, which fails for the workspace in -// FAKE_NPM_FAIL_PUBLISH. Every other command succeeds without doing anything. -const fakeNpm = `const { appendFileSync } = require("node:fs"); -const args = process.argv.slice(2); -appendFileSync(process.env.FAKE_NPM_LOG, JSON.stringify(args) + "\\n"); -if (args[0] === "view") { - if (process.env.FAKE_NPM_OFFLINE) { - process.stderr.write("npm error code ECONNREFUSED\\n"); - process.exit(1); - } - const spec = args[1]; - const published = (process.env.FAKE_NPM_PUBLISHED ?? "").split(","); - if (!published.includes(spec)) { - process.stdout.write(JSON.stringify({ error: { code: "E404" } })); - process.exit(1); - } else if (args[2] === "gitHead") { - const head = process.env.FAKE_NPM_GITHEAD; - process.stdout.write(head ? JSON.stringify(head) : ""); - } else { - process.stdout.write(JSON.stringify(spec.slice(spec.lastIndexOf("@") + 1))); - } -} else if (args[0] === "publish" && args[2] === process.env.FAKE_NPM_FAIL_PUBLISH) { - process.stderr.write("npm error code E500\\n"); - process.exit(1); -} -`; +const fakeNpm = join(import.meta.dirname, "fixtures", "fake-npm.mjs"); const manifests = { "packages/a": { name: "@fixture/a", version: "1.0.0" }, @@ -57,28 +32,87 @@ const manifests = { devDependencies: { "left-pad": "^1.3.0" }, }, }; +// The `workspaces` order is the reverse of the dependency order (c needs b, which needs a) +const workspaces = ["packages/c", "packages/b", "packages/a"]; +const names = ["@fixture/a", "@fixture/b", "@fixture/c"]; +const files = ["README.md", "index.js", "package.json"]; + +// SemVer 2.0.0 (https://semver.org), without build metadata (dropped by npm) +const validVersions = [ + "0.0.0", + "1.2.3", + "10.20.30", + "999999999999.0.0", + "1.0.0-0", + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-0.3.7", + "1.0.0-x.7.z.92", + "1.0.0-x-y-z.--", + "1.0.0-0a", + "1.0.0--1", + "1.0.0-rc.1", + "0.0.0-development", +]; +const invalidVersions = [ + "1", + "1.2", + "1.2.3.4", + "01.2.3", + "1.02.3", + "1.2.03", + "-1.2.3", + "1.2.x", + "2.x", + "v1.2.3", + " 1.2.3", + "1.2.3 ", + "1.2.3\n", + "1.2.3-", + "1.2.3-01", + "1.2.3-00", + "1.2.3-a..b", + "1.2.3-a.", + "1.2.3-.a", + "1.2.3-a_b", + "1.2.3-ä", + "1.2.3+", + "1.2.3+a..b", + "1.2.3+a_b", + "1.2.3-a+", +]; +// Valid SemVer, but npm publishes `1.0.0+a` as `1.0.0` +const buildVersions = ["1.0.0+build", "1.0.0+001", "1.0.0-rc.1+build.1-x"]; describe("scripts/release.mjs", () => { let root = ""; + let out = ""; + const path = (file) => join(root, file); const writeJSON = (file, data) => { - writeFileSync(join(root, file), `${JSON.stringify(data, null, 2)}\n`); + writeFileSync(path(file), `${JSON.stringify(data, null, 2)}\n`); }; - const readJSON = (file) => JSON.parse(readFileSync(join(root, file), "utf8")); + const readJSON = (file) => JSON.parse(readFileSync(path(file), "utf8")); const release = (args, env = {}) => - spawnSync(process.execPath, [join(root, "scripts/release.mjs"), ...args], { + spawnSync(process.execPath, [path("scripts/release.mjs"), ...args], { encoding: "utf8", + timeout: 60_000, env: { ...process.env, - npm_execpath: join(root, "npm.cjs"), - FAKE_NPM_LOG: join(root, "npm.log"), + npm_execpath: fakeNpm, + FAKE_NPM_LOG: path("npm.log"), + FAKE_NPM_REGISTRY: path("registry.json"), ...env, }, }); + const succeed = (args, env) => { + const { status, stderr } = release(args, env); + equal(status, 0, stderr); + return stderr; + }; - // A repository whose HEAD is the tagged release (without the hooks and the - // signing of the user) + // Without the hooks and the signing of the user const git = (...args) => { const { status, stdout, stderr } = spawnSync( "git", @@ -93,207 +127,670 @@ describe("scripts/release.mjs", () => { "tag.gpgsign=false", "-c", "init.defaultBranch=main", + "-c", + "core.autocrlf=false", ...args, ], - { cwd: root, encoding: "utf8" }, + { cwd: root, encoding: "utf8", timeout: 60_000 }, ); equal(status, 0, stderr); return stdout.trim(); }; - const tagRelease = () => { - git("init", "-q"); + const commit = (message) => { git("add", "-A"); - git("commit", "-q", "--no-verify", "-m", "release"); - git("tag", "v1.0.0"); + git("commit", "-q", "--no-verify", "--allow-empty", "-m", message); return git("rev-parse", "HEAD"); }; - const npmCalls = (command) => { - const log = join(root, "npm.log"); + const npmCalls = (...commands) => { + const log = path("npm.log"); return existsSync(log) ? readFileSync(log, "utf8") .split("\n") .filter((line) => line !== "") .map((line) => JSON.parse(line)) - .filter(([name]) => name === command) + .filter(([name]) => commands.includes(name)) : []; }; + function registry() { + return existsSync(path("registry.json")) ? readJSON("registry.json") : {}; + } + const release2 = () => readJSON(`release/${manifestFile}`).packages; + + // `version` and `pack` of 2.0.0, tagged as semantic-release does + const prepare = () => { + succeed(["version", "2.0.0"]); + succeed(["pack", out]); + git("tag", "v2.0.0"); + rmSync(path("npm.log")); + return release2(); + }; beforeEach(() => { root = mkdtempSync(join(tmpdir(), "binden-release-")); - mkdirSync(join(root, "scripts")); - for (const script of ["release.mjs", "npm.mjs"]) { + out = path("release"); + mkdirSync(path("scripts")); + for (const script of ["release.mjs", "npm.mjs", "tarballs.mjs"]) { copyFileSync( join(import.meta.dirname, script), - join(root, "scripts", script), + path(`scripts/${script}`), ); } - writeFileSync(join(root, "npm.cjs"), fakeNpm); - writeJSON("package.json", { - private: true, - workspaces: Object.keys(manifests), - }); + writeJSON( + "scripts/pack-files.json", + Object.fromEntries(names.map((name) => [name, files])), + ); + writeJSON("package.json", { private: true, workspaces }); + writeJSON("package-lock.json", { lockfileVersion: 3 }); + writeFileSync(path(".gitignore"), "npm.log\nregistry.json\nrelease/\n"); for (const [dir, manifest] of Object.entries(manifests)) { - mkdirSync(join(root, dir), { recursive: true }); + mkdirSync(path(dir), { recursive: true }); writeJSON(`${dir}/package.json`, manifest); + writeFileSync(path(`${dir}/index.js`), `export const name = "${dir}";\n`); + writeFileSync(path(`${dir}/README.md`), `# ${manifest.name}\n`); } + git("init", "-q"); + commit("initial"); }); afterEach(() => { rmSync(root, { force: true, recursive: true }); }); - it("check passes when the workspaces are in lockstep", () => { - const { status, stderr } = release(["check"]); - equal(status, 0); - match(stderr, /3 workspaces in lockstep at 1\.0\.0/u); - }); + describe("check", () => { + it("passes when the workspaces are in lockstep", () => { + match(succeed(["check"]), /3 workspaces in lockstep at 1\.0\.0/u); + }); - it("check fails on a version drift", () => { - writeJSON("packages/c/package.json", { - ...manifests["packages/c"], - version: "1.0.1", + it("fails on a version drift", () => { + writeJSON("packages/a/package.json", { + ...manifests["packages/a"], + version: "1.0.1", + }); + const { status, stderr } = release(["check"]); + equal(status, 1); + match(stderr, /packages\/a: version 1\.0\.1 != 1\.0\.0/u); }); - const { status, stderr } = release(["check"]); - equal(status, 1); - match(stderr, /packages\/c: version 1\.0\.1 != 1\.0\.0/u); - }); - it("check fails on an internal range drift", () => { - writeJSON("packages/b/package.json", { - ...manifests["packages/b"], - dependencies: { "@fixture/a": "^0.9.0" }, + it("fails on an internal range drift", () => { + writeJSON("packages/b/package.json", { + ...manifests["packages/b"], + dependencies: { "@fixture/a": "^0.9.0" }, + }); + const { status, stderr } = release(["check"]); + equal(status, 1); + match( + stderr, + /dependencies\.@fixture\/a is \^0\.9\.0, expected \^1\.0\.0/u, + ); }); - const { status, stderr } = release(["check"]); - equal(status, 1); - match( - stderr, - /dependencies\.@fixture\/a is \^0\.9\.0, expected \^1\.0\.0/u, - ); }); - it("verify passes when no workspace is published (E404)", () => { - const { status, stderr } = release(["verify", "2.0.0"]); - equal(status, 0); - match(stderr, /no workspace is published at 2\.0\.0 yet/u); - }); + describe("versions", () => { + it("accepts SemVer versions", () => { + for (const version of validVersions) { + const { status, stderr } = release(["verify", version]); + equal(status, 0, `${version}: ${stderr}`); + } + }); - it("verify fails when a workspace is already published", () => { - const env = { FAKE_NPM_PUBLISHED: "@fixture/b@2.0.0" }; - const { status, stderr } = release(["verify", "2.0.0"], env); - equal(status, 1); - match(stderr, /Already published at 2\.0\.0: @fixture\/b/u); - }); + it("refuses anything else", () => { + for (const version of invalidVersions) { + for (const command of ["verify", "version"]) { + const { status, stderr } = release([command, version]); + equal(status, 1, version); + ok(stderr.includes(`Invalid version: ${version}`), stderr); + } + } + deepEqual(npmCalls("view", "install"), []); + equal(readJSON("packages/a/package.json").version, "1.0.0"); + }); - it("verify fails on registry errors (not only on E404)", () => { - const env = { FAKE_NPM_OFFLINE: "1" }; - const { status, stderr } = release(["verify", "2.0.0"], env); - equal(status, 1); - match(stderr, /ECONNREFUSED/u); + it("refuses build metadata, which npm drops", () => { + for (const version of buildVersions) { + for (const command of ["verify", "version"]) { + const { status, stderr } = release([command, version]); + equal(status, 1, version); + ok( + stderr.includes( + `Build metadata is not supported by npm: ${version}`, + ), + stderr, + ); + } + } + deepEqual(npmCalls("view", "install"), []); + }); }); - it("verify and version reject an invalid version", () => { - for (const command of ["verify", "version"]) { - const { status, stderr } = release([command, "2.x"]); + describe("verify", () => { + it("passes when no workspace is published (E404)", () => { + match( + succeed(["verify", "2.0.0"]), + /no workspace is published at 2\.0\.0 yet/u, + ); + deepEqual(npmCalls("view").length, 3); + }); + + it("fails when a workspace is already published", () => { + writeJSON("registry.json", { "@fixture/b@2.0.0": { version: "2.0.0" } }); + const { status, stderr } = release(["verify", "2.0.0"]); equal(status, 1); - match(stderr, /Invalid version: 2\.x/u); - } - }); + match(stderr, /Already published at 2\.0\.0: @fixture\/b/u); + }); - it("version updates every workspace and the lockfile", () => { - const { status } = release(["version", "2.0.0"]); - equal(status, 0); - deepEqual(readJSON("packages/a/package.json").version, "2.0.0"); - deepEqual(readJSON("packages/b/package.json").dependencies, { - "@fixture/a": "^2.0.0", - }); - const c = readJSON("packages/c/package.json"); - deepEqual(c.version, "2.0.0"); - deepEqual(c.peerDependencies, { "@fixture/b": "^2.0.0" }); - deepEqual(c.devDependencies, { "left-pad": "^1.3.0" }); - deepEqual(npmCalls("install"), [ - ["install", "--package-lock-only", "--ignore-scripts"], - ]); + it("fails on registry errors (not only on E404)", () => { + for (const error of ["ECONNREFUSED", "E401", "E500", "crash"]) { + const env = { + FAKE_NPM_VIEW_ERROR: error, + FAKE_NPM_VIEW_ERROR_SPEC: "@fixture/a@2.0.0", + }; + const { status, stderr } = release(["verify", "2.0.0"], env); + equal(status, 1, error); + doesNotMatch(stderr, /no workspace is published/u); + } + }); }); - it("publish publishes every workspace in the workspaces order", () => { - const { status } = release(["publish"]); - equal(status, 0); - deepEqual( - npmCalls("publish"), - Object.keys(manifests).map((dir) => ["publish", "--workspace", dir]), - ); - }); + describe("version", () => { + it("updates every workspace, its gitHead and the lockfile", () => { + const head = git("rev-parse", "HEAD"); + succeed(["version", "2.0.0"]); + deepEqual(readJSON("packages/a/package.json"), { + name: "@fixture/a", + version: "2.0.0", + gitHead: head, + }); + deepEqual(readJSON("packages/b/package.json").dependencies, { + "@fixture/a": "^2.0.0", + }); + const c = readJSON("packages/c/package.json"); + deepEqual(c.version, "2.0.0"); + deepEqual(c.gitHead, head); + deepEqual(c.peerDependencies, { "@fixture/b": "^2.0.0" }); + deepEqual(c.devDependencies, { "left-pad": "^1.3.0" }); + deepEqual(npmCalls("install"), [ + [ + "install", + "--package-lock-only", + "--ignore-scripts", + "--no-audit", + "--no-fund", + ], + ]); + }); - it("publish refuses to publish workspaces out of lockstep", () => { - writeJSON("packages/b/package.json", { - ...manifests["packages/b"], - version: "1.0.1", + it("records the commit of HEAD", () => { + succeed(["version", "2.0.0"]); + const head = commit("later"); + succeed(["version", "2.0.1"]); + equal(readJSON("packages/b/package.json").gitHead, head); }); - const { status, stderr } = release(["publish", "--skip-existing"]); - equal(status, 1); - match(stderr, /not in lockstep/u); - deepEqual(npmCalls("publish"), []); }); - it("publish stops at the first workspace that fails", () => { - const env = { FAKE_NPM_FAIL_PUBLISH: "packages/b" }; - const { status, stderr } = release(["publish"], env); - equal(status, 1); - match(stderr, /E500/u); - deepEqual(npmCalls("publish"), [ - ["publish", "--workspace", "packages/a"], - ["publish", "--workspace", "packages/b"], - ]); - }); + describe("pack", () => { + it("packs every workspace once and lists the archives in dependency order", () => { + const head = git("rev-parse", "HEAD"); + succeed(["version", "2.0.0"]); + succeed(["pack", out]); + deepEqual(npmCalls("pack"), [ + ["pack", "--workspaces", "--pack-destination", out, "--loglevel=warn"], + ]); + deepEqual( + release2(), + names.map((name) => { + const file = `${name.slice(1).replace("/", "-")}-2.0.0.tgz`; + return { + name, + version: "2.0.0", + sha: head, + integrity: integrity(readFileSync(join(out, file))), + file, + }; + }), + ); + }); + + it("refuses a directory that is not empty", () => { + succeed(["version", "2.0.0"]); + mkdirSync(out); + writeFileSync(join(out, "stale.tgz"), ""); + const { status, stderr } = release(["pack", out]); + equal(status, 1); + match(stderr, /release is not empty/u); + deepEqual(npmCalls("pack"), []); + }); + + it("refuses changes that are not committed", () => { + succeed(["version", "2.0.0"]); + appendFileSync(path("packages/a/index.js"), "// changed\n"); + const { status, stderr } = release(["pack", out]); + equal(status, 1); + match(stderr, /Uncommitted changes: packages\/a\/index\.js\n/u); + deepEqual(npmCalls("pack"), []); + }); - it("publish --skip-existing publishes the rest of the tagged release", () => { - const head = tagRelease(); - const env = { - FAKE_NPM_PUBLISHED: "@fixture/a@1.0.0", - FAKE_NPM_GITHEAD: head, - }; - const { status } = release(["publish", "--skip-existing"], env); - equal(status, 0); - deepEqual(npmCalls("publish"), [ - ["publish", "--workspace", "packages/b"], - ["publish", "--workspace", "packages/c"], - ]); + it("refuses workspaces out of lockstep", () => { + writeJSON("packages/b/package.json", { + ...manifests["packages/b"], + version: "1.0.1", + }); + const { status, stderr } = release(["pack", out]); + equal(status, 1); + match(stderr, /not in lockstep/u); + deepEqual(npmCalls("pack"), []); + }); + + it("refuses archives without the gitHead of HEAD", () => { + const before = git("rev-parse", "HEAD"); + const { status, stderr } = release(["pack", out]); + equal(status, 1); + for (const name of names) { + const file = `${name.slice(1).replace("/", "-")}-1.0.0.tgz`; + ok( + stderr.includes(`${file}: gitHead undefined, expected ${before}`), + stderr, + ); + } + equal(existsSync(join(out, manifestFile)), false); + + succeed(["version", "2.0.0"]); + const head = commit("later"); + rmSync(out, { recursive: true }); + const later = release(["pack", out]); + equal(later.status, 1); + match( + later.stderr, + new RegExp( + `fixture-a-2\\.0\\.0\\.tgz: gitHead ${before}, expected ${head}`, + "u", + ), + ); + equal(existsSync(join(out, manifestFile)), false); + }); + + it("refuses archives of another version or package", () => { + succeed(["version", "2.0.0"]); + for (const [patch, errors] of [ + [ + { "packages/b": { version: "2.0.1" } }, + [/fixture-b-2\.0\.0\.tgz: version 2\.0\.1, expected 2\.0\.0/u], + ], + [ + { "packages/c": { name: "@fixture/x" } }, + [ + /@fixture\/c: no archive/u, + /fixture-c-2\.0\.0\.tgz: @fixture\/x is not a workspace/u, + ], + ], + [ + { "packages/b": { name: "@fixture/a" } }, + [/another archive of @fixture\/a/u, /@fixture\/b: no archive/u], + ], + ]) { + rmSync(out, { force: true, recursive: true }); + const env = { FAKE_NPM_PACK_PATCH: JSON.stringify(patch) }; + const { status, stderr } = release(["pack", out], env); + equal(status, 1); + for (const error of errors) { + match(stderr, error); + } + equal(existsSync(join(out, manifestFile)), false); + } + }); + + it("refuses archives with unexpected or missing files", () => { + writeFileSync(path("packages/a/secret.pem"), "secret"); + writeJSON("scripts/pack-files.json", { + "@fixture/a": files, + "@fixture/b": [...files, "dist/index.js"], + "@fixture/c": files, + }); + commit("files"); + succeed(["version", "2.0.0"]); + const { status, stderr } = release(["pack", out]); + equal(status, 1); + match( + stderr, + /fixture-a-2\.0\.0\.tgz: @fixture\/a: unexpected secret\.pem/u, + ); + match( + stderr, + /fixture-b-2\.0\.0\.tgz: @fixture\/b: missing dist\/index\.js/u, + ); + equal(existsSync(join(out, manifestFile)), false); + }); + + it("fails when npm cannot pack", () => { + succeed(["version", "2.0.0"]); + const { status } = release(["pack", out], { FAKE_NPM_FAIL_PACK: "1" }); + equal(status, 1); + equal(existsSync(join(out, manifestFile)), false); + }); + + it("refuses circular dependencies", () => { + writeJSON("packages/a/package.json", { + ...manifests["packages/a"], + peerDependencies: { "@fixture/c": "^1.0.0" }, + }); + commit("circular"); + succeed(["version", "2.0.0"]); + const { status, stderr } = release(["pack", out]); + equal(status, 1); + match( + stderr, + /Circular dependencies between @fixture\/c, @fixture\/b, @fixture\/a/u, + ); + }); }); - it("publish --skip-existing refuses packages of another commit", () => { - tagRelease(); - for (const head of ["0".repeat(40), ""]) { - const env = { - FAKE_NPM_PUBLISHED: "@fixture/a@1.0.0", - FAKE_NPM_GITHEAD: head, - }; - const { status, stderr } = release(["publish", "--skip-existing"], env); + describe("publish", () => { + it("publishes the archives in dependency order once all are checked", () => { + const packages = prepare(); + succeed(["publish", out]); + deepEqual(npmCalls("view", "publish"), [ + ...names.map((name) => ["view", `${name}@2.0.0`, "--json"]), + ...packages.map(({ file }) => ["publish", join(out, file)]), + ]); + deepEqual( + registry(), + Object.fromEntries( + packages.map(({ name, sha, integrity: sum }) => [ + `${name}@2.0.0`, + { name, version: "2.0.0", gitHead: sha, dist: { integrity: sum } }, + ]), + ), + ); + }); + + it("--dry-run does not upload anything", () => { + const packages = prepare(); + succeed(["publish", out, "--dry-run"]); + deepEqual( + npmCalls("publish"), + packages.map(({ file }) => ["publish", join(out, file), "--dry-run"]), + ); + deepEqual(registry(), {}); + }); + + it("publishes nothing when a version is already published", () => { + const [a] = prepare(); + const document = { gitHead: a.sha, dist: { integrity: a.integrity } }; + writeJSON("registry.json", { "@fixture/a@2.0.0": document }); + const { status, stderr } = release(["publish", out]); equal(status, 1); - match(stderr, /@fixture\/a@1\.0\.0 was published from/u); - } - deepEqual(npmCalls("publish"), []); + match( + stderr, + /Nothing is published:\n {2}@fixture\/a@2\.0\.0 is already published/u, + ); + deepEqual(npmCalls("publish"), []); + }); + + it("publishes nothing when the registry has another archive", () => { + const [, b] = prepare(); + for (const document of [ + { gitHead: "0".repeat(40), dist: { integrity: b.integrity } }, + { gitHead: b.sha, dist: { integrity: integrity("other") } }, + { dist: { integrity: b.integrity } }, + { gitHead: b.sha }, + ]) { + writeJSON("registry.json", { "@fixture/b@2.0.0": document }); + for (const options of [[], ["--skip-existing"]]) { + const { status, stderr } = release(["publish", out, ...options]); + equal(status, 1); + match( + stderr, + /@fixture\/b@2\.0\.0 is published from another archive/u, + ); + } + } + deepEqual(npmCalls("publish"), []); + }); + + it("publishes nothing on registry errors (not only on E404)", () => { + prepare(); + for (const error of ["ECONNREFUSED", "E401", "E500", "crash"]) { + const env = { + FAKE_NPM_VIEW_ERROR: error, + FAKE_NPM_VIEW_ERROR_SPEC: "@fixture/c@2.0.0", + }; + for (const options of [[], ["--skip-existing"]]) { + const { status } = release(["publish", out, ...options], env); + equal(status, 1, error); + } + } + deepEqual(npmCalls("publish"), []); + deepEqual(registry(), {}); + }); + + it("recovers a release that stopped at any package", () => { + const packages = prepare(); + const calls = packages.map(({ file }) => ["publish", join(out, file)]); + for (const [index, { name }] of packages.entries()) { + rmSync(path("registry.json"), { force: true }); + rmSync(path("npm.log"), { force: true }); + const failed = release(["publish", out], { + FAKE_NPM_FAIL_PUBLISH: name, + }); + equal(failed.status, 1); + deepEqual( + Object.keys(registry()), + names.slice(0, index).map((published) => `${published}@2.0.0`), + ); + + rmSync(path("npm.log")); + const stderr = succeed(["publish", out, "--skip-existing"]); + deepEqual(npmCalls("publish"), calls.slice(index)); + for (const skipped of names.slice(0, index)) { + ok( + stderr.includes(`skip ${skipped}@2.0.0: already published`), + stderr, + ); + } + deepEqual( + Object.keys(registry()).sort(), + names.map((published) => `${published}@2.0.0`), + ); + } + }); + + it("publishes nothing when an archive changed after pack", () => { + const [, b] = prepare(); + appendFileSync(join(out, b.file), "x"); + const { status, stderr } = release(["publish", out]); + equal(status, 1); + match( + stderr, + /fixture-b-2\.0\.0\.tgz: integrity sha512-\S+ differs from/u, + ); + deepEqual(npmCalls("view", "publish"), []); + }); + + it("publishes nothing when an archive is not the one of the release", () => { + const packages = prepare(); + const head = git("rev-parse", "HEAD"); + const manifest = (fields) => + JSON.stringify({ ...readJSON("packages/b/package.json"), ...fields }); + for (const [content, error] of [ + [ + { "package.json": manifest({ gitHead: "0".repeat(40) }) }, + new RegExp( + `fixture-b-2\\.0\\.0\\.tgz: gitHead 0{40}, expected ${head}`, + "u", + ), + ], + [ + { "package.json": manifest({ version: "2.0.1" }) }, + /fixture-b-2\.0\.0\.tgz: version 2\.0\.1, expected 2\.0\.0/u, + ], + [ + { "package.json": manifest({ name: "@fixture/a" }) }, + /fixture-b-2\.0\.0\.tgz: name @fixture\/a, expected @fixture\/b/u, + ], + [ + { "package.json": manifest({}), "secret.pem": "" }, + /fixture-b-2\.0\.0\.tgz: @fixture\/b: unexpected secret\.pem/u, + ], + ]) { + const data = npmTarball({ + "README.md": "", + "index.js": "", + ...content, + }); + writeFileSync(join(out, packages[1].file), data); + const [a, b, c] = packages; + writeJSON(`release/${manifestFile}`, { + packages: [a, { ...b, integrity: integrity(data) }, c], + }); + const { status, stderr } = release(["publish", out]); + equal(status, 1); + match(stderr, error); + } + deepEqual(npmCalls("view", "publish"), []); + }); + + it("publishes nothing from another commit than the one of the archives", () => { + prepare(); + const head = commit("later"); + const { status, stderr } = release(["publish", out]); + equal(status, 1); + match( + stderr, + new RegExp( + `fixture-a-2\\.0\\.0\\.tgz: 2\\.0\\.0 from [\\da-f]{40}, expected 2\\.0\\.0 from ${head} \\(HEAD\\)`, + "u", + ), + ); + deepEqual(npmCalls("view", "publish"), []); + }); + + it("publishes nothing when the manifest is not the one of the workspaces", () => { + const packages = prepare(); + const head = git("rev-parse", "HEAD"); + for (const [manifest, error] of [ + [packages.slice(0, 2), /@fixture\/c: not in release-manifest\.json/u], + [[...packages, packages[0]], /another archive of @fixture\/a/u], + [ + [...packages, { ...packages[0], name: "@fixture/x" }], + /fixture-a-2\.0\.0\.tgz: @fixture\/x is not a workspace/u, + ], + [ + packages.map((entry) => ({ ...entry, version: "2.0.1" })), + /: 2\.0\.1 from [\da-f]{40}, expected 2\.0\.0 from/u, + ], + [ + packages.map((entry) => ({ ...entry, sha: "0".repeat(40) })), + new RegExp( + `: 2\\.0\\.0 from 0{40}, expected 2\\.0\\.0 from ${head} \\(HEAD\\)`, + "u", + ), + ], + ]) { + writeJSON(`release/${manifestFile}`, { packages: manifest }); + const { status, stderr } = release(["publish", out]); + equal(status, 1); + match(stderr, error); + } + deepEqual(npmCalls("view", "publish"), []); + }); + + it("refuses workspaces out of lockstep", () => { + prepare(); + writeJSON("packages/b/package.json", { + ...readJSON("packages/b/package.json"), + version: "2.0.1", + }); + const { status, stderr } = release(["publish", out, "--skip-existing"]); + equal(status, 1); + match(stderr, /not in lockstep/u); + deepEqual(npmCalls("view", "publish"), []); + }); + + it("--skip-existing publishes from the commit of the tag only", () => { + prepare(); + commit("later"); + const { status, stderr } = release(["publish", out, "--skip-existing"]); + equal(status, 1); + match(stderr, /is not the commit of v2\.0\.0/u); + + git("tag", "-d", "v2.0.0"); + const untagged = release(["publish", out, "--skip-existing"]); + equal(untagged.status, 1); + deepEqual(npmCalls("view", "publish"), []); + }); + + it("fails without the manifest", () => { + succeed(["version", "2.0.0"]); + const { status, stderr } = release(["publish", out]); + equal(status, 1); + match(stderr, /ENOENT/u); + deepEqual(npmCalls("view", "publish"), []); + }); }); - it("publish --skip-existing publishes from the commit of the tag only", () => { - tagRelease(); - writeFileSync(join(root, "later"), ""); - git("add", "-A"); - git("commit", "-q", "--no-verify", "-m", "later"); - const { status, stderr } = release(["publish", "--skip-existing"]); - equal(status, 1); - match(stderr, /is not the commit of v1\.0\.0/u); - deepEqual(npmCalls("publish"), []); - - git("tag", "-d", "v1.0.0"); - const untagged = release(["publish", "--skip-existing"]); - equal(untagged.status, 1); - deepEqual(npmCalls("publish"), []); + // The commands that semantic-release runs are the ones that the rehearsal runs + it(".releaserc.json verifies, then versions and packs, then publishes the archives", () => { + const { plugins } = JSON.parse( + readFileSync(join(import.meta.dirname, "../.releaserc.json"), "utf8"), + ); + const [, exec] = plugins.find( + (plugin) => + Array.isArray(plugin) && plugin[0] === "@semantic-release/exec", + ); + match( + exec.verifyReleaseCmd, + /^node scripts\/release\.mjs verify \$\{nextRelease\.version\}$/u, + ); + match( + exec.prepareCmd, + /^node scripts\/release\.mjs version \$\{nextRelease\.version\} && node scripts\/release\.mjs pack "\$RELEASE_DIR"$/u, + ); + equal(exec.publishCmd, 'node scripts/release.mjs publish "$RELEASE_DIR"'); }); - it("fails on an unknown command", () => { - const { status, stderr } = release(["deploy"]); - equal(status, 1); - match(stderr, /Unknown command: deploy/u); + describe("arguments", () => { + it("fails on an unknown command", () => { + const { status, stderr } = release(["deploy"]); + equal(status, 1); + match(stderr, /Unknown command: deploy/u); + }); + + it("fails on an unknown option", () => { + for (const args of [ + ["pack", "release", "--skip-existing"], + ["publish", "release", "--force"], + ["version", "2.0.0", "--dry-run"], + ]) { + const { status, stderr } = release(args); + equal(status, 1); + match(stderr, /Unknown option: --/u); + } + deepEqual(npmCalls("view", "install", "pack", "publish"), []); + }); + + it("fails without exactly one operand", () => { + for (const [command, operand] of [ + ["verify", "version"], + ["version", "version"], + ["pack", "dir"], + ["publish", "dir"], + ]) { + for (const args of [ + [command], + [command, ""], + [command, "1.0.0", "2.0.0"], + ]) { + const { status, stderr } = release(args); + equal(status, 1); + ok( + stderr.includes( + `Usage: scripts/release.mjs ${command} <${operand}>`, + ), + stderr, + ); + } + } + deepEqual(npmCalls("view", "install", "pack", "publish"), []); + }); }); }); diff --git a/scripts/tarballs.mjs b/scripts/tarballs.mjs new file mode 100644 index 00000000..7e156891 --- /dev/null +++ b/scripts/tarballs.mjs @@ -0,0 +1,169 @@ +// The npm tarballs of a release: what is inside an archive (read without a `tar` +// command, whose options differ between platforms), its integrity as npm records it +// (`dist.integrity`), and the manifest that `scripts/release.mjs pack` writes next +// to the archives, which the checks and the publication read instead of packing again. +import { basename, join } from "node:path"; +import { createHash } from "node:crypto"; +import { gunzipSync } from "node:zlib"; +import { readFileSync } from "node:fs"; + +/** The file that lists the archives of a release, in the directory of the archives */ +export const manifestFile = "release-manifest.json"; + +const blockSize = 512; +const root = "package/"; + +/** The integrity of `data` as npm records it (`dist.integrity`): `sha512-` */ +export function integrity(data) { + return `sha512-${createHash("sha512").update(data).digest("base64")}`; +} + +function text(header, offset, length) { + const bytes = header.subarray(offset, offset + length); + const end = bytes.indexOf(0); + return bytes.subarray(0, end === -1 ? length : end).toString("utf8"); +} + +function octal(header, offset, length) { + const value = text(header, offset, length).trim(); + if (!/^[0-7]+$/u.test(value)) { + throw new Error(`Invalid tar header: ${JSON.stringify(value)}`); + } + return Number.parseInt(value, 8); +} + +// The `path` record of a pax extended header (records are ` =\n`) +function paxPath(data) { + let path = null; + for (let offset = 0; offset < data.length;) { + const space = data.indexOf(0x20, offset); + const length = data.subarray(offset, space).toString("utf8"); + const end = offset + Number(length); + if ( + space === -1 || + !/^\d+$/u.test(length) || + end <= space || + end > data.length + ) { + throw new Error("Invalid pax header"); + } + const record = data.subarray(space + 1, end - 1).toString("utf8"); + const separator = record.indexOf("="); + if (record.slice(0, separator) === "path") { + path = record.slice(separator + 1); + } + offset = end; + } + return path; +} + +/** + * The regular files of a gzipped npm tarball, by their path inside its `package/` + * directory. Directories are skipped; links and other entries are refused. + */ +export function readTarball(data) { + const tar = gunzipSync(data); + const files = new Map(); + let longPath = null; + for (let offset = 0; offset + blockSize <= tar.length;) { + const header = tar.subarray(offset, offset + blockSize); + if (header.every((byte) => byte === 0)) { + break; + } + const size = octal(header, 124, 12); + const start = offset + blockSize; + const content = tar.subarray(start, start + size); + if (content.length !== size) { + throw new Error("Truncated tarball"); + } + offset = start + Math.ceil(size / blockSize) * blockSize; + + const type = String.fromCharCode(header[156]); + if (type === "x") { + longPath = paxPath(content); + } else if (type === "L") { + longPath = text(content, 0, content.length); + } else { + const prefix = text(header, 345, 155); + const name = text(header, 0, 100); + const path = longPath ?? (prefix === "" ? name : `${prefix}/${name}`); + longPath = null; + if (!path.startsWith(root)) { + throw new Error(`Unexpected path in the tarball: ${path}`); + } + if (["0", "\0", "7"].includes(type)) { + files.set(path.slice(root.length), content); + } else if (type !== "5") { + throw new Error(`Unsupported entry in the tarball: ${path} (${type})`); + } + } + } + return files; +} + +/** The `package.json` and the sorted file list of a gzipped npm tarball */ +export function inspectTarball(data) { + const files = readTarball(data); + const manifest = files.get("package.json"); + if (typeof manifest === "undefined") { + throw new Error("No package.json in the tarball"); + } + return { manifest: JSON.parse(manifest), files: [...files.keys()].sort() }; +} + +/** + * Compares the files of each package with the expected ones + * (`scripts/pack-files.json`), and returns the differences + */ +export function compareFiles(expected, actual) { + const errors = []; + for (const name of new Set([ + ...Object.keys(expected), + ...Object.keys(actual), + ])) { + const want = new Set(expected[name] ?? []); + const have = new Set(actual[name] ?? []); + for (const file of have.difference(want)) { + errors.push(`${name}: unexpected ${file}`); + } + for (const file of want.difference(have)) { + errors.push(`${name}: missing ${file}`); + } + } + return errors; +} + +/** + * Reads the manifest of the archives in `dir`, and checks that every archive is + * still the one that was packed (its integrity). Returns the packages in the order + * of the manifest (the dependency order), with the path of their archive. + */ +export function readRelease(dir) { + const { packages } = JSON.parse( + readFileSync(join(dir, manifestFile), "utf8"), + ); + if (!Array.isArray(packages) || packages.length === 0) { + throw new Error(`${manifestFile}: no packages`); + } + return packages.map((entry) => { + const fields = ["name", "version", "sha", "integrity", "file"]; + if (!fields.every((field) => typeof entry?.[field] === "string")) { + throw new Error( + `${manifestFile}: invalid entry ${JSON.stringify(entry)} (${fields.join(", ")})`, + ); + } + // The archives are next to the manifest: nothing else is read or published + const { file } = entry; + if (basename(file) !== file || !file.endsWith(".tgz")) { + throw new Error(`${manifestFile}: invalid file name ${file}`); + } + const path = join(dir, file); + const actual = integrity(readFileSync(path)); + if (actual !== entry.integrity) { + throw new Error( + `${file}: integrity ${actual} differs from ${entry.integrity} (${manifestFile})`, + ); + } + return { ...entry, path }; + }); +} diff --git a/scripts/tarballs.test.mjs b/scripts/tarballs.test.mjs new file mode 100644 index 00000000..649e79a2 --- /dev/null +++ b/scripts/tarballs.test.mjs @@ -0,0 +1,265 @@ +import { afterEach, beforeEach, describe, it } from "node:test"; +import { + compareFiles, + inspectTarball, + integrity, + manifestFile, + readRelease, + readTarball, +} from "./tarballs.mjs"; +import { deepEqual, equal, match, throws } from "node:assert/strict"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { npmTarball, paxRecord, tar } from "./fixtures/tar.mjs"; +import { createHash } from "node:crypto"; +import { gzipSync } from "node:zlib"; +import { join } from "node:path"; +import { npm } from "./npm.mjs"; +import { pathToFileURL } from "node:url"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; + +const tarballs = pathToFileURL(join(import.meta.dirname, "tarballs.mjs")).href; + +// The error of readTarball() for `data`, in a child process with a deadline: a parser that +// never ends fails the test (the test runner cannot interrupt a synchronous loop) +function readError(data) { + const { stdout, error } = spawnSync( + process.execPath, + [ + "--input-type=module", + "-e", + `import { readTarball } from ${JSON.stringify(tarballs)}; +import { readFileSync } from "node:fs"; +try { + readTarball(readFileSync(0)); +} catch (error) { + process.stdout.write(error.message); +}`, + ], + { input: data, encoding: "utf8", timeout: 10_000 }, + ); + if (error) { + throw error; + } + return stdout; +} + +const long = `dist/${"a".repeat(60)}/${"b".repeat(60)}/index.js`; +const longer = `dist/${"c".repeat(120)}.js`; + +const contents = (files) => + Object.fromEntries( + [...files].map(([path, content]) => [path, content.toString("utf8")]), + ); + +describe("scripts/tarballs.mjs", () => { + let dir = ""; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "binden-tarballs-")); + }); + + afterEach(() => { + rmSync(dir, { force: true, recursive: true }); + }); + + it("reads the archives of npm, and computes their integrity like npm", () => { + const pkg = join(dir, "pkg"); + const files = { + "package.json": JSON.stringify({ name: "fixture", version: "1.0.0" }), + "index.js": "export {};\n", + // Split into the ustar prefix, and in a pax header (longer than 155 bytes) + [long]: "long\n", + [longer]: "longer\n", + }; + for (const [path, content] of Object.entries(files)) { + mkdirSync(join(pkg, path, ".."), { recursive: true }); + writeFileSync(join(pkg, path), content); + } + const [packed] = JSON.parse( + npm(["pack", "--json", "--ignore-scripts", "--pack-destination", dir], { + cwd: pkg, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 60_000, + }), + ); + const data = readFileSync(join(dir, packed.filename)); + equal(integrity(data), packed.integrity); + deepEqual(contents(readTarball(data)), files); + }); + + it("computes the integrity as the base64 of the SHA-512 digest", () => { + const digest = createHash("sha512").update("data").digest("base64"); + equal(integrity(Buffer.from("data")), `sha512-${digest}`); + }); + + it("reads the long paths of ustar, pax and GNU headers", () => { + const data = tar([ + { name: "package/", type: "5" }, + { name: "index.js", prefix: "package/dist", content: "a" }, + { + name: "PaxHeader/x", + type: "x", + content: `${paxRecord("mtime", "1")}${paxRecord("path", `package/${longer}`)}`, + }, + { name: "ignored", content: "b" }, + { name: "././@LongLink", type: "L", content: `package/${long}\0` }, + { name: "ignored", content: "c" }, + { name: "package/README.md", content: "d" }, + { name: "PaxHeader/y", type: "x", content: paxRecord("mtime", "1") }, + { name: "package/package.json", content: "{}", type: "\0" }, + ]); + deepEqual(contents(readTarball(data)), { + "dist/index.js": "a", + [longer]: "b", + [long]: "c", + "README.md": "d", + "package.json": "{}", + }); + }); + + it("refuses entries outside package/, links and broken archives", () => { + for (const [entries, error] of [ + [ + [{ name: "other/index.js" }], + /Unexpected path in the tarball: other\/index\.js/u, + ], + [ + [{ name: "index.js", prefix: "pkg" }], + /Unexpected path in the tarball: pkg\/index\.js/u, + ], + [ + [{ name: "package/link", type: "2" }], + /Unsupported entry in the tarball: package\/link \(2\)/u, + ], + [ + [ + { + name: "pax_global_header", + type: "g", + content: paxRecord("path", "x"), + }, + ], + /Unexpected path in the tarball: pax_global_header/u, + ], + [ + [{ name: "package/a", content: "abc", size: 4096 }], + /Truncated tarball/u, + ], + [ + [{ name: "PaxHeader/x", type: "x", content: "99 path=a\n" }], + /Invalid pax header/u, + ], + [ + [{ name: "PaxHeader/x", type: "x", content: "x path=a\n" }], + /Invalid pax header/u, + ], + [ + [{ name: "PaxHeader/x", type: "x", content: "path=a\n" }], + /Invalid pax header/u, + ], + [ + [{ name: "PaxHeader/x", type: "x", content: "0 \n" }], + /Invalid pax header/u, + ], + ]) { + match(readError(tar(entries)), error); + } + const header = Buffer.alloc(1024); + header.write("package/a", 0); + header.write("12345678\0", 124); + throws( + () => readTarball(gzipSync(header)), + /Invalid tar header: "12345678"/u, + ); + throws( + () => readTarball(Buffer.from("not gzip")), + /incorrect header check/u, + ); + }); + + it("inspects the package.json and the sorted files of an archive", () => { + const data = npmTarball({ + "package.json": '{"name":"a","gitHead":"0"}', + "b.js": "", + "a.js": "", + }); + deepEqual(inspectTarball(data), { + manifest: { name: "a", gitHead: "0" }, + files: ["a.js", "b.js", "package.json"], + }); + throws( + () => inspectTarball(npmTarball({ "index.js": "" })), + /No package\.json in the tarball/u, + ); + }); + + it("compares the files of each package with the expected ones", () => { + deepEqual(compareFiles({ a: ["x", "y"] }, { a: ["y", "x"] }), []); + deepEqual( + compareFiles({ a: ["x", "y"], c: ["z"] }, { a: ["x", "w"], b: ["v"] }), + ["a: unexpected w", "a: missing y", "c: missing z", "b: unexpected v"], + ); + }); + + describe("readRelease", () => { + const entry = (file, data) => ({ + name: "a", + version: "1.0.0", + sha: "0".repeat(40), + integrity: integrity(data), + file, + }); + const write = (packages) => { + writeFileSync(join(dir, manifestFile), JSON.stringify({ packages })); + }; + + it("lists the archives of the manifest, in its order", () => { + writeFileSync(join(dir, "b.tgz"), "b"); + writeFileSync(join(dir, "a.tgz"), "a"); + const packages = [entry("b.tgz", "b"), entry("a.tgz", "a")]; + write(packages); + deepEqual( + readRelease(dir), + packages.map((value) => ({ ...value, path: join(dir, value.file) })), + ); + }); + + it("refuses an archive that changed", () => { + writeFileSync(join(dir, "a.tgz"), "changed"); + write([entry("a.tgz", "a")]); + throws( + () => readRelease(dir), + /a\.tgz: integrity sha512-\S+ differs from sha512-\S+ \(release-manifest\.json\)/u, + ); + }); + + it("refuses an invalid manifest", () => { + const target = dir; + writeFileSync(join(target, "a.tgz"), "a"); + for (const [packages, error] of [ + [null, /no packages/u], + [[], /no packages/u], + [{}, /no packages/u], + [[null], /invalid entry null/u], + [[{ ...entry("a.tgz", "a"), sha: 1 }], /invalid entry/u], + [[{ ...entry("a.tgz", "a"), file: null }], /invalid entry/u], + [[entry("../a.tgz", "a")], /invalid file name \.\.\/a\.tgz/u], + [[entry("sub/a.tgz", "a")], /invalid file name sub\/a\.tgz/u], + [[entry("a.tar", "a")], /invalid file name a\.tar/u], + ]) { + write(packages); + throws(() => readRelease(target), error); + } + rmSync(join(dir, manifestFile)); + throws(() => readRelease(dir), /ENOENT/u); + }); + }); +}); From fb86b4f7ccff7ca623e9e056e9934513f5c8be4a Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:46:20 +0300 Subject: [PATCH 135/675] ci: check the archives of the release rehearsal `pack:check` and `consumer:check` packed the workspaces of the checkout themselves, with the development version and without `gitHead`: CI never checked the archives that the release publishes. `scripts/pack-check.mjs --tarballs ` and `scripts/consumer-check.mjs --tarballs ` now take the archives of `release-manifest.json` (whose integrity they check) instead of packing again, and the rehearsal keeps its archives in the directory that it is given. The `lint` job rehearses the release first, then checks the files and the consumers of those archives (the next version, packed from the commit, as the release job packs it). Without `--tarballs`, both checks still pack the working tree. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 18 ++--- README.md | 30 +++++--- scripts/consumer-check.mjs | 29 +++++++- scripts/consumer-check.test.mjs | 120 ++++++++++++++++++++++++++++++++ scripts/pack-check.mjs | 75 ++++++++++++-------- scripts/pack-check.test.mjs | 98 ++++++++++++++++++++++++-- scripts/release-rehearsal.mjs | 15 +++- 7 files changed, 325 insertions(+), 60 deletions(-) create mode 100644 scripts/consumer-check.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56b7c256..344083b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -61,17 +61,17 @@ jobs: - name: Tooling tests (release scripts, test runner) run: npm run test:tooling - - name: Packed files (scripts/pack-files.json) - run: npm run pack:check - - - name: Consumer check (packed packages, TypeScript and ESM) - run: npm run consumer:check - # The next version, its release notes and its archives, made as the release job makes # them (scripts/release.mjs `verify`, `version`, `pack` and `publish --dry-run`): - # nothing is tagged or published + # nothing is tagged or published. The next checks use these archives. - name: Release rehearsal - run: npm run release:rehearsal + run: npm run release:rehearsal -- "$RUNNER_TEMP/release" + + - name: Packed files (scripts/pack-files.json) + run: node scripts/pack-check.mjs --tarballs "$RUNNER_TEMP/release" + + - name: Consumer check (the archives of the rehearsal, TypeScript and ESM) + run: node scripts/consumer-check.mjs --tarballs "$RUNNER_TEMP/release" - name: Docs run: npm run docs:build diff --git a/README.md b/README.md index dfe23cf7..db8b2345 100644 --- a/README.md +++ b/README.md @@ -19,19 +19,31 @@ API documentation: Requires Node.js `>=24.14.1` and npm `>=11.11.0`, as the packages do. ```bash -npm install # installs all workspaces and builds them (`prepare`) -npm run build # type checks and compiles every workspace (including tests) -npm test # node:test (via tsx) in every workspace -npm run coverage # tests with c8 coverage (coverage/lcov.info) -npm run eslint # type-aware ESLint for the whole repository -npm run docs:build # TypeDoc for all packages (docs/) -npm run consumer:check # the packed packages and the README examples in a fresh project -npm run release:rehearsal # the next version, its notes and its archives, without releasing -npm run ci:all # everything CI runs +npm install # installs all workspaces and builds them (`prepare`) +npm run build # type checks and compiles every workspace (including tests) +npm test # node:test (via tsx) in every workspace +npm run coverage # tests with c8 coverage (coverage/lcov.info) +npm run eslint # type-aware ESLint for the whole repository +npm run test:tooling # the tests of the release scripts and the tooling (scripts/*.test.mjs) +npm run docs:build # TypeDoc for all packages (docs/) +npm run pack:check # the files that every package publishes (scripts/pack-files.json) +npm run consumer:check # the packed packages and the README examples in a fresh project +npm run release:rehearsal # the next version, its release notes and its archives, without releasing +npm run ci:all # everything CI runs ``` Commands for a single package: `npm test -w packages/binden`. +`npm run pack:check` and `npm run consumer:check` pack the workspaces of the working tree. CI checks +the archives of the release rehearsal instead, the way the release job makes them (from the +commit, with the next version and its `gitHead`), without packing them again: + +```bash +npm run release:rehearsal -- # keeps the archives and their manifest in +node scripts/pack-check.mjs --tarballs +node scripts/consumer-check.mjs --tarballs +``` + `npm run consumer:check` runs a consumer of the packed packages against a real server, but only type checks the TypeScript examples of the READMEs: the placeholders that they use (`app`, `context`, …) are declared, and their behaviour is not tested. diff --git a/scripts/consumer-check.mjs b/scripts/consumer-check.mjs index 6bc43006..b8b8b227 100644 --- a/scripts/consumer-check.mjs +++ b/scripts/consumer-check.mjs @@ -10,6 +10,12 @@ // `worker_threads.TransferListItem`, which `@types/node` 26 removed; // - TypeScript 7 (the native compiler, which the tooling of this repository does // not support yet) and `@types/node` 24. +// +// node scripts/consumer-check.mjs packs the workspaces itself +// node scripts/consumer-check.mjs --tarballs the archives that +// `scripts/release.mjs pack ` +// prepared (their integrity is checked) +import { join, resolve } from "node:path"; import { mkdirSync, mkdtempSync, @@ -19,8 +25,8 @@ import { writeFileSync, } from "node:fs"; import { execFileSync } from "node:child_process"; -import { join } from "node:path"; import { npm } from "./npm.mjs"; +import { readRelease } from "./tarballs.mjs"; import { tmpdir } from "node:os"; const root = join(import.meta.dirname, ".."); @@ -160,15 +166,32 @@ function run(command, args, cwd = dir) { } } -try { +/** The prepared archives of `--tarballs `, or the workspaces packed into `dir` */ +function pack() { + const index = process.argv.indexOf("--tarballs"); + if (index !== -1) { + const prepared = process.argv[index + 1] ?? ""; + if (prepared === "" || prepared.startsWith("--")) { + throw new Error("Usage: scripts/consumer-check.mjs --tarballs "); + } + const archives = readRelease(resolve(prepared)); + process.stderr.write( + `[consumer] ${archives.length} prepared archives of ${prepared}\n`, + ); + return archives.map(({ path }) => path); + } run( "npm", ["pack", "--workspaces", "--pack-destination", dir, "--loglevel=warn"], root, ); - const tarballs = readdirSync(dir) + return readdirSync(dir) .filter((file) => file.endsWith(".tgz")) .map((file) => join(dir, file)); +} + +try { + const tarballs = pack(); writeFileSync( join(dir, "package.json"), diff --git a/scripts/consumer-check.test.mjs b/scripts/consumer-check.test.mjs new file mode 100644 index 00000000..5e14b3b8 --- /dev/null +++ b/scripts/consumer-check.test.mjs @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, it } from "node:test"; +import { + appendFileSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { deepEqual, equal, match } from "node:assert/strict"; +import { integrity, manifestFile } from "./tarballs.mjs"; +import { join } from "node:path"; +import { npmTarball } from "./fixtures/tar.mjs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; + +// A fake npm CLI (run as `npm_execpath`) that logs its arguments and fails, so that the +// check stops at its first npm command (before anything is installed) +const fakeNpm = `require("node:fs").appendFileSync(process.env.FAKE_NPM_LOG, JSON.stringify(process.argv.slice(2)) + "\\n"); +process.exit(1); +`; + +describe("scripts/consumer-check.mjs", () => { + let dir = ""; + + const check = (...args) => + spawnSync( + process.execPath, + [join(import.meta.dirname, "consumer-check.mjs"), ...args], + { + encoding: "utf8", + timeout: 60_000, + env: { + ...process.env, + npm_execpath: join(dir, "npm.cjs"), + FAKE_NPM_LOG: join(dir, "npm.log"), + }, + }, + ); + const npmCalls = () => { + try { + return readFileSync(join(dir, "npm.log"), "utf8") + .split("\n") + .filter((line) => line !== "") + .map((line) => JSON.parse(line)); + } catch { + return []; + } + }; + + // Archives prepared by `scripts/release.mjs pack` + const prepare = () => { + const packages = ["a", "b"].map((name) => { + const file = `${name}-1.0.0.tgz`; + const data = npmTarball({ "package.json": JSON.stringify({ name }) }); + writeFileSync(join(dir, file), data); + return { + name, + version: "1.0.0", + sha: "0", + integrity: integrity(data), + file, + }; + }); + writeFileSync(join(dir, manifestFile), JSON.stringify({ packages })); + return packages.map(({ file }) => join(dir, file)); + }; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "binden-consumer-check-")); + writeFileSync(join(dir, "npm.cjs"), fakeNpm); + }); + + afterEach(() => { + rmSync(dir, { force: true, recursive: true }); + }); + + it("--tarballs installs the prepared archives without packing", () => { + const archives = prepare(); + const { status } = check("--tarballs", dir); + equal(status, 1); + const [[command, ...args], ...rest] = npmCalls(); + equal(command, "install"); + deepEqual( + args.filter((arg) => arg.endsWith(".tgz")), + archives, + ); + deepEqual(rest, []); + }); + + it("packs the workspaces without --tarballs", () => { + const { status } = check(); + equal(status, 1); + deepEqual( + npmCalls().map(([command]) => command), + ["pack"], + ); + }); + + it("--tarballs refuses an archive that changed", () => { + const [, b] = prepare(); + appendFileSync(b, "x"); + const { status, stderr } = check("--tarballs", dir); + equal(status, 1); + match(stderr, /b-1\.0\.0\.tgz: integrity sha512-\S+ differs from/u); + deepEqual(npmCalls(), []); + }); + + it("--tarballs needs a directory", () => { + for (const args of [ + ["--tarballs"], + ["--tarballs", ""], + ["--tarballs", "--x"], + ]) { + const { status, stderr } = check(...args); + equal(status, 1); + match(stderr, /Usage: scripts\/consumer-check\.mjs --tarballs /u); + } + deepEqual(npmCalls(), []); + }); +}); diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs index 4e281b12..4bb784e3 100644 --- a/scripts/pack-check.mjs +++ b/scripts/pack-check.mjs @@ -1,48 +1,63 @@ #!/usr/bin/env node -// Checks the files that `npm pack` puts in the tarball of every workspace against +// Checks the files that the tarball of every workspace contains against // `scripts/pack-files.json`: a file that is missing (e.g. a module not built) or that // should not be published (e.g. tests, sources or secrets) fails the check. -// The workspaces must be built before (`npm run build`): their `prepack` does not run. // -// node scripts/pack-check.mjs check -// node scripts/pack-check.mjs --update write the current files as the expected ones +// node scripts/pack-check.mjs `npm pack --dry-run` of the workspaces, +// which must be built before (their +// `prepack` does not run) +// node scripts/pack-check.mjs --tarballs the archives that `scripts/release.mjs +// pack ` prepared, without packing +// again (their integrity is checked) +// --update write the current files as the expected +// ones +import { compareFiles, inspectTarball, readRelease } from "./tarballs.mjs"; +import { join, resolve } from "node:path"; import { readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { npm } from "./npm.mjs"; const root = join(import.meta.dirname, ".."); const expectedFile = join(root, "scripts", "pack-files.json"); +const args = process.argv.slice(2); +const tarballs = args.indexOf("--tarballs"); -const packed = JSON.parse( - npm(["pack", "--dry-run", "--json", "--workspaces", "--ignore-scripts"], { - cwd: root, - encoding: "utf8", - stdio: ["ignore", "pipe", "inherit"], - }), -); -const actual = Object.fromEntries( - packed.map(({ name, files }) => [name, files.map(({ path }) => path).sort()]), -); +function packedFiles() { + const packed = JSON.parse( + npm(["pack", "--dry-run", "--json", "--workspaces", "--ignore-scripts"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }), + ); + return Object.fromEntries( + packed.map(({ name, files }) => [ + name, + files.map(({ path }) => path).sort(), + ]), + ); +} -if (process.argv.includes("--update")) { +function preparedFiles(dir) { + if (typeof dir === "undefined" || dir === "" || dir.startsWith("--")) { + throw new Error("Usage: scripts/pack-check.mjs --tarballs "); + } + return Object.fromEntries( + readRelease(resolve(dir)).map(({ name, path }) => [ + name, + inspectTarball(readFileSync(path)).files, + ]), + ); +} + +const actual = + tarballs === -1 ? packedFiles() : preparedFiles(args[tarballs + 1]); + +if (args.includes("--update")) { writeFileSync(expectedFile, `${JSON.stringify(actual, null, 2)}\n`); process.stderr.write(`[pack] ${expectedFile} updated\n`); } else { const expected = JSON.parse(readFileSync(expectedFile, "utf8")); - const errors = []; - for (const name of new Set([ - ...Object.keys(expected), - ...Object.keys(actual), - ])) { - const want = new Set(expected[name] ?? []); - const have = new Set(actual[name] ?? []); - for (const file of have.difference(want)) { - errors.push(`${name}: unexpected ${file}`); - } - for (const file of want.difference(have)) { - errors.push(`${name}: missing ${file}`); - } - } + const errors = compareFiles(expected, actual); if (errors.length > 0) { throw new Error( `The packed files differ from scripts/pack-files.json (run \`node scripts/pack-check.mjs --update\` if this is expected):\n ${errors.join("\n ")}`, diff --git a/scripts/pack-check.test.mjs b/scripts/pack-check.test.mjs index 4f3b6423..70281908 100644 --- a/scripts/pack-check.test.mjs +++ b/scripts/pack-check.test.mjs @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, it } from "node:test"; import { + appendFileSync, copyFileSync, mkdirSync, mkdtempSync, @@ -8,12 +9,16 @@ import { writeFileSync, } from "node:fs"; import { deepEqual, equal, match } from "node:assert/strict"; +import { integrity, manifestFile } from "./tarballs.mjs"; import { join } from "node:path"; +import { npmTarball } from "./fixtures/tar.mjs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; -// A fake npm CLI (run as `npm_execpath`) whose `npm pack --json` prints FAKE_NPM_PACK -const fakeNpm = `process.stdout.write(process.env.FAKE_NPM_PACK);\n`; +// A fake npm CLI (run as `npm_execpath`) whose `npm pack --json` prints FAKE_NPM_PACK, +// and that fails for anything else +const fakeNpm = `if (process.argv[2] !== "pack") process.exit(1); +process.stdout.write(process.env.FAKE_NPM_PACK);\n`; const packed = (files) => JSON.stringify( @@ -32,6 +37,7 @@ describe("scripts/pack-check.mjs", () => { [join(root, "scripts/pack-check.mjs"), ...args], { encoding: "utf8", + timeout: 60_000, env: { ...process.env, npm_execpath: join(root, "npm.cjs"), @@ -40,10 +46,32 @@ describe("scripts/pack-check.mjs", () => { }, ); + // Archives prepared by `scripts/release.mjs pack` (only the parts that are checked) + const prepare = (files) => { + const dir = join(root, "release"); + mkdirSync(dir); + const packages = Object.entries(files).map(([name, paths]) => { + const file = `${name}-1.0.0.tgz`; + const data = npmTarball( + Object.fromEntries(paths.map((path) => [path, "{}"])), + ); + writeFileSync(join(dir, file), data); + return { + name, + version: "1.0.0", + sha: "0", + integrity: integrity(data), + file, + }; + }); + writeFileSync(join(dir, manifestFile), JSON.stringify({ packages })); + return dir; + }; + beforeEach(() => { root = mkdtempSync(join(tmpdir(), "binden-pack-check-")); mkdirSync(join(root, "scripts")); - for (const script of ["pack-check.mjs", "npm.mjs"]) { + for (const script of ["pack-check.mjs", "npm.mjs", "tarballs.mjs"]) { copyFileSync( join(import.meta.dirname, script), join(root, "scripts", script), @@ -52,7 +80,7 @@ describe("scripts/pack-check.mjs", () => { writeFileSync(join(root, "npm.cjs"), fakeNpm); writeFileSync( join(root, "scripts/pack-files.json"), - JSON.stringify({ a: ["LICENSE", "dist/index.js"] }), + JSON.stringify({ a: ["LICENSE", "dist/index.js", "package.json"] }), ); }); @@ -61,13 +89,17 @@ describe("scripts/pack-check.mjs", () => { }); it("passes with the expected files", () => { - const { status, stderr } = check({ a: ["dist/index.js", "LICENSE"] }); + const { status, stderr } = check({ + a: ["dist/index.js", "package.json", "LICENSE"], + }); equal(status, 0); match(stderr, /1 workspaces with the expected files/u); }); it("fails with an unexpected or a missing file", () => { - const { status, stderr } = check({ a: ["LICENSE", "dist/test/a.test.js"] }); + const { status, stderr } = check({ + a: ["LICENSE", "dist/test/a.test.js", "package.json"], + }); equal(status, 1); match(stderr, /a: unexpected dist\/test\/a\.test\.js/u); match(stderr, /a: missing dist\/index\.js/u); @@ -75,7 +107,7 @@ describe("scripts/pack-check.mjs", () => { it("fails with an unexpected workspace", () => { const { status, stderr } = check({ - a: ["LICENSE", "dist/index.js"], + a: ["LICENSE", "dist/index.js", "package.json"], b: ["x"], }); equal(status, 1); @@ -90,4 +122,56 @@ describe("scripts/pack-check.mjs", () => { { a: ["a", "b"] }, ); }); + + describe("--tarballs", () => { + it("checks the prepared archives without packing", () => { + const dir = prepare({ a: ["package.json", "LICENSE", "dist/index.js"] }); + const { status, stderr } = check({}, "--tarballs", dir); + equal(status, 0, stderr); + match(stderr, /1 workspaces with the expected files/u); + }); + + it("fails with an unexpected or a missing file or archive", () => { + const dir = prepare({ + a: ["package.json", "LICENSE", "secret.pem"], + b: ["package.json"], + }); + const expected = { a: ["LICENSE", "dist/index.js", "package.json"] }; + const { status, stderr } = check(expected, "--tarballs", dir); + equal(status, 1); + match(stderr, /a: unexpected secret\.pem/u); + match(stderr, /a: missing dist\/index\.js/u); + match(stderr, /b: unexpected package\.json/u); + }); + + it("fails when an archive changed after it was prepared", () => { + const dir = prepare({ a: ["package.json", "LICENSE", "dist/index.js"] }); + appendFileSync(join(dir, "a-1.0.0.tgz"), "x"); + const { status, stderr } = check({}, "--tarballs", dir); + equal(status, 1); + match(stderr, /a-1\.0\.0\.tgz: integrity sha512-\S+ differs from/u); + }); + + it("--update writes the files of the archives", () => { + const dir = prepare({ a: ["package.json", "b"] }); + const { status } = check({}, "--tarballs", dir, "--update"); + equal(status, 0); + deepEqual( + JSON.parse(readFileSync(join(root, "scripts/pack-files.json"), "utf8")), + { a: ["b", "package.json"] }, + ); + }); + + it("fails without a directory", () => { + for (const args of [ + ["--tarballs"], + ["--tarballs", ""], + ["--tarballs", "--update"], + ]) { + const { status, stderr } = check({}, ...args); + equal(status, 1); + match(stderr, /Usage: scripts\/pack-check\.mjs --tarballs /u); + } + }); + }); }); diff --git a/scripts/release-rehearsal.mjs b/scripts/release-rehearsal.mjs index 26e89c80..b5823fc9 100644 --- a/scripts/release-rehearsal.mjs +++ b/scripts/release-rehearsal.mjs @@ -7,7 +7,14 @@ // their manifest), and `publish --dry-run` (the registry check, then `npm publish // --dry-run` of every archive). When no release is due, the current version is // packed. +// +// node scripts/release-rehearsal.mjs [] +// +// (missing or empty) keeps the archives and their manifest, for the checks that +// run on them (`scripts/pack-check.mjs --tarballs `, `scripts/consumer-check.mjs +// --tarballs `). import { execFileSync, spawnSync } from "node:child_process"; +import { join, resolve } from "node:path"; import { mkdtempSync, readFileSync, @@ -15,15 +22,18 @@ import { symlinkSync, writeFileSync, } from "node:fs"; -import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { tmpdir } from "node:os"; const root = join(import.meta.dirname, ".."); +const [destination] = process.argv.slice(2); const dir = mkdtempSync(join(tmpdir(), "binden-rehearsal-")); const mirror = join(dir, "mirror.git"); const work = join(dir, "work"); -const archives = join(dir, "archives"); +const archives = + typeof destination === "undefined" + ? join(dir, "archives") + : resolve(destination); const plugins = new Set([ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", @@ -112,6 +122,7 @@ try { node(["scripts/release.mjs", "publish", archives, "--dry-run"]); log(`the next release is ${version}`); } + log(`archives: ${archives}`); } finally { rmSync(dir, { force: true, recursive: true }); } From b35f969213e667e9cd8fb63dd47d8d0769065ad7 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:47:05 +0300 Subject: [PATCH 136/675] ci: check the input of the recover job and pin the runtime of publication The `recover` job checked out the tag of its input and ran `npm ci` (the `prepare` script of that commit) before anything was validated, took any failure of `gh release view` for a missing GitHub Release, and could run at the same time as a release. It now checks the version (SemVer, as `scripts/release.mjs` does), that its tag exists and that the commit of the tag is on `main` before anything of the repository runs, and creates the GitHub Release only when the API answers 404 (any other answer, or none, fails). The `release` and `recover` jobs share the `publish` concurrency group (never cancelled), run in the `release` environment (the name that the npm trusted publisher expects), and run on Node.js 24.16.0 with its npm 11.13.0, which they check: both pack the same archives, bit for bit, so that a recovery recognizes the packages that the release published. The shell steps of the recover job are tested with bash, git and a fake gh. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 84 ++++++++++++-- README.md | 23 ++-- scripts/release.test.mjs | 28 +++++ scripts/workflow.test.mjs | 229 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 345 insertions(+), 19 deletions(-) create mode 100644 scripts/workflow.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 344083b4..04a763c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,6 +152,14 @@ jobs: timeout-minutes: 30 + # Its name is the one of the npm trusted publisher + environment: release + + # One publication at a time (with the `recover` job), never cancelled half-way + concurrency: + group: publish + cancel-in-progress: false + permissions: contents: write issues: write @@ -166,12 +174,20 @@ jobs: with: fetch-depth: 0 + # The same runtime as the `recover` job, so that both pack the same archives + # (bit for bit) from the same commit - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 24 - check-latest: true + node-version: 24.16.0 registry-url: https://registry.npmjs.org + - name: Runtime (Node.js 24.16.0 and its npm 11.13.0) + run: | + node --version + npm --version + test "$(node --version)" = v24.16.0 + test "$(npm --version)" = 11.13.0 + - name: Install dependencies and build the workspaces run: npm ci @@ -200,6 +216,12 @@ jobs: timeout-minutes: 30 + environment: release + + concurrency: + group: publish + cancel-in-progress: false + permissions: contents: write id-token: write @@ -209,22 +231,52 @@ jobs: VERSION: ${{ inputs.version }} steps: + # Nothing of the repository runs (not even `npm ci`, which runs its `prepare` script) + # before the version, its tag and the commit of the tag are checked. SemVer 2.0.0 + # without build metadata, as `scripts/release.mjs` (and its tests) validate it. + - name: Check the version + run: | + semver='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?$' + if ! LC_ALL=C bash -c '[[ $1 =~ $2 ]]' bash "$VERSION" "$semver"; then + echo "::error::The version is not a SemVer version (without build metadata)" + exit 1 + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: v${{ inputs.version }} fetch-depth: 0 + persist-credentials: false + + # A tag that is not on `main` (pushed by hand, or on another branch) is never published + - name: Check the tag and its commit + run: | + if ! commit=$(git rev-parse --verify --quiet "refs/tags/v$VERSION^{commit}"); then + echo "::error::The tag v$VERSION does not exist" + exit 1 + fi + if ! git merge-base --is-ancestor "$commit" refs/remotes/origin/main; then + echo "::error::The commit of v$VERSION ($commit) is not on main" + exit 1 + fi + git checkout --quiet --detach "$commit" - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 24 - check-latest: true + node-version: 24.16.0 registry-url: https://registry.npmjs.org + - name: Runtime (Node.js 24.16.0 and its npm 11.13.0) + run: | + node --version + npm --version + test "$(node --version)" = v24.16.0 + test "$(npm --version)" = 11.13.0 + - name: Install dependencies and build the workspaces run: npm ci - # Packs the archives again, checks the registry for all of them, then publishes the - # missing ones with provenance + # Packs the archives again (the same ones, bit for bit, as the release job), checks the + # registry for all of them, then publishes the missing ones with provenance - name: Publish the packages that are not published yet run: | node scripts/release.mjs version "$VERSION" @@ -234,14 +286,26 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: true - # Its notes are generated by GitHub (from the merged pull requests) rather than - # semantic-release + # Only a 404 means that the release is missing: any other answer (or none) fails. Its + # notes are generated by GitHub (from the merged pull requests) rather than + # semantic-release. - name: Create the GitHub Release when it is missing run: | - if ! gh release view "v$VERSION" > /dev/null 2>&1; then + status=0 + gh api --include "repos/$GITHUB_REPOSITORY/releases/tags/v$VERSION" \ + > "$RUNNER_TEMP/github-release.http" || status=$? + line='' + IFS= read -r line < "$RUNNER_TEMP/github-release.http" || true + missing='^HTTP/[0-9.]+ 404( |$)' + if [ "$status" -eq 0 ]; then + echo "The GitHub Release v$VERSION exists" + elif [[ $line =~ $missing ]]; then previous=$(git describe --tags --abbrev=0 --match 'v*' "v$VERSION^") gh release create "v$VERSION" --verify-tag --title "v$VERSION" \ --generate-notes --notes-start-tag "$previous" + else + echo "::error::Cannot tell whether the GitHub Release v$VERSION exists: ${line:-no response}" + exit 1 fi env: GH_TOKEN: ${{ github.token }} diff --git a/README.md b/README.md index db8b2345..170e5618 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,10 @@ They do not affect the published packages. Releases are fully automated by [semantic-release](https://github.com/semantic-release/semantic-release) on every push to `main` (see [`.releaserc.json`](.releaserc.json)), in the `release` job of the CI -workflow: +workflow. The job runs in the `release` environment (the one that the npm trusted publisher of the +packages expects), one publication at a time (with the `recover` job), on Node.js 24.16.0 with its +npm 11.13.0 (both checked): the `recover` job uses the same runtime, so that both pack the same +archives, bit for bit, from the same commit. 1. the next version is derived from [Conventional Commits](https://conventionalcommits.org) since the last `v*` tag, and [`scripts/release.mjs`](scripts/release.mjs) `verify` checks that no @@ -121,20 +124,22 @@ in a clone, where `scripts/release.mjs` runs `verify`, `version`, `pack` and `pu When a release fails after its tag was pushed (for example, some packages were published, but not all of them), the next release would start after that tag: recover it instead, by running the CI -workflow manually (Actions → CI → Run workflow) with its version. The `recover` job checks out -the commit of the tag, and: +workflow manually (Actions → CI → Run workflow) with its version. The `recover` job: -1. packs the archives again with `scripts/release.mjs version` and `pack` (the same archives as the - release job); -2. publishes with `scripts/release.mjs publish --skip-existing`: the registry is checked for all the +1. runs nothing of the repository (not even `npm ci`) before it has checked that the version is a + SemVer version, that its tag exists, and that the commit of the tag is on `main`; +2. checks out that commit, and packs the archives again with `scripts/release.mjs version` and + `pack` (the same archives as the release job); +3. publishes with `scripts/release.mjs publish --skip-existing`: the registry is checked for all the packages first, a version that is already published is skipped only when it is the same archive (its `gitHead` and `dist.integrity` on npm), and any other difference, or any registry error other than a 404, publishes nothing. The missing packages are published with provenance; -3. creates the GitHub Release when it is missing, with notes generated by GitHub. +4. creates the GitHub Release when the GitHub API answers 404 for it (any other answer fails the + job), with notes generated by GitHub. The same can be done by hand from a checkout of the tag (packages published from outside of CI have -no provenance). With another runtime than the one of the release job, the archives may differ from -the published ones, and `publish` then refuses to publish anything: +no provenance). With another runtime than Node.js 24.16.0 and npm 11.13.0, the archives may differ +from the published ones, and `publish` then refuses to publish anything: ```bash git checkout v diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs index 20a95302..3ef30f33 100644 --- a/scripts/release.test.mjs +++ b/scripts/release.test.mjs @@ -261,6 +261,34 @@ describe("scripts/release.mjs", () => { } deepEqual(npmCalls("view", "install"), []); }); + + // The recover job validates its input with bash before anything of the repository runs + it( + "are validated the same way by the recover job", + { skip: process.platform === "win32" && "bash is not portable there" }, + () => { + const workflow = readFileSync( + join(import.meta.dirname, "../.github/workflows/ci.yml"), + "utf8", + ); + const { pattern } = + /^\s+semver='(?[^']+)'$/mu.exec(workflow)?.groups ?? {}; + ok(pattern, "semver='…' in the recover job"); + for (const [versions, expected] of [ + [validVersions, 0], + [[...invalidVersions, ...buildVersions], 1], + ]) { + for (const version of versions) { + const { status } = spawnSync( + "bash", + ["-c", "[[ $1 =~ $2 ]]", "bash", version, pattern], + { env: { ...process.env, LC_ALL: "C" }, timeout: 10_000 }, + ); + equal(status, expected, JSON.stringify(version)); + } + } + }, + ); }); describe("verify", () => { diff --git a/scripts/workflow.test.mjs b/scripts/workflow.test.mjs new file mode 100644 index 00000000..04c99725 --- /dev/null +++ b/scripts/workflow.test.mjs @@ -0,0 +1,229 @@ +import { after, before, describe, it } from "node:test"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { equal, match, ok } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; + +const workflow = readFileSync( + join(import.meta.dirname, "../.github/workflows/ci.yml"), + "utf8", +); + +/** The script (`run: |`) of a step of a job of the CI workflow */ +function script(job, name) { + const lines = workflow.split("\n"); + const start = lines.indexOf(` ${job}:`); + const end = lines.findIndex( + (line, index) => index > start && /^ {2}\S/u.test(line), + ); + const steps = lines.slice(start, end === -1 ? lines.length : end); + const step = steps.findIndex((line) => line.trim() === `- name: ${name}`); + const run = steps.findIndex( + (line, index) => index > step && line.trim() === "run: |", + ); + ok(start !== -1 && step !== -1 && run !== -1, `${job}: ${name}`); + const indent = steps[run].indexOf("run"); + const body = []; + for (const line of steps.slice(run + 1)) { + if (line.trim() !== "" && line.search(/\S/u) <= indent) { + break; + } + body.push(line); + } + const margin = body[0].search(/\S/u); + return body.map((line) => line.slice(margin)).join("\n"); +} + +// The recover job checks its input with bash before anything of the repository runs, then +// creates the GitHub Release only when the API answers 404 (the shell of GitHub Actions) +describe( + "the recover job of .github/workflows/ci.yml", + { skip: process.platform === "win32" && "bash is not portable there" }, + () => { + let dir = ""; + + const run = (code, cwd, env) => + spawnSync( + "bash", + ["--noprofile", "--norc", "-eo", "pipefail", "-c", code], + { + cwd, + encoding: "utf8", + timeout: 60_000, + env: { + ...process.env, + PATH: `${join(dir, "bin")}:${process.env.PATH}`, + GH_LOG: join(dir, "gh.log"), + GITHUB_REPOSITORY: "owner/repo", + RUNNER_TEMP: dir, + ...env, + }, + }, + ); + const git = (cwd, ...args) => { + const { status, stdout, stderr } = spawnSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "-c", + "tag.gpgsign=false", + "-c", + "init.defaultBranch=main", + ...args, + ], + { cwd, encoding: "utf8", timeout: 60_000 }, + ); + equal(status, 0, stderr); + return stdout.trim(); + }; + const commit = (cwd, content) => { + writeFileSync(join(cwd, "file"), content); + git(cwd, "add", "-A"); + git(cwd, "commit", "-q", "--no-verify", "-m", content); + }; + // A clone like the one of actions/checkout (`fetch-depth: 0`) + const checkout = () => { + const clone = mkdtempSync(join(dir, "clone-")); + git(dir, "clone", "-q", "--no-local", join(dir, "origin"), clone); + return clone; + }; + function ghCalls() { + const log = join(dir, "gh.log"); + return existsSync(log) ? readFileSync(log, "utf8") : ""; + } + + before(() => { + dir = mkdtempSync(join(tmpdir(), "binden-workflow-")); + // A fake gh: `gh api --include` answers FAKE_GH_STATUS (nothing when it is `none`) + mkdirSync(join(dir, "bin")); + writeFileSync( + join(dir, "bin", "gh"), + `#!/bin/sh +echo "$*" >> "$GH_LOG" +if [ "$1" = api ]; then + case "$FAKE_GH_STATUS" in + none) echo "error connecting to api.github.com" >&2; exit 1;; + 200) printf 'HTTP/2.0 200 OK\\r\\n\\r\\n{}'; exit 0;; + *) printf 'HTTP/2.0 %s Status\\r\\n\\r\\n{}' "$FAKE_GH_STATUS"; exit 1;; + esac +fi +`, + ); + chmodSync(join(dir, "bin", "gh"), 0o755); + // v1.0.0 and v1.1.0 on main, v2.0.0 on another branch + const origin = join(dir, "origin"); + mkdirSync(origin); + git(origin, "init", "-q"); + commit(origin, "one"); + git(origin, "tag", "-a", "v1.0.0", "-m", "v1.0.0"); + commit(origin, "two"); + git(origin, "tag", "v1.1.0"); + git(origin, "checkout", "-q", "-b", "other"); + commit(origin, "three"); + git(origin, "tag", "v2.0.0"); + git(origin, "checkout", "-q", "main"); + }); + + after(() => { + rmSync(dir, { force: true, recursive: true }); + }); + + it("refuses a version that is not SemVer, without running it", () => { + const check = script("recover", "Check the version"); + const marker = join(dir, "injected"); + for (const version of ["1.0.0", "1.0.0-rc.1"]) { + equal(run(check, dir, { VERSION: version }).status, 0, version); + } + for (const version of [ + "", + "1.0", + "1.0.0+build", + `$(touch ${marker})`, + "1.0.0\n::warning::injected", + ]) { + const { status, stdout } = run(check, dir, { VERSION: version }); + equal(status, 1, version); + equal( + stdout, + "::error::The version is not a SemVer version (without build metadata)\n", + ); + } + equal(existsSync(marker), false); + }); + + it("checks out the commit of the tag only when it is on main", () => { + const check = script("recover", "Check the tag and its commit"); + const clone = checkout(); + equal(run(check, clone, { VERSION: "1.0.0" }).status, 0); + equal( + git(clone, "rev-parse", "HEAD"), + git(clone, "rev-parse", "v1.0.0^{commit}"), + ); + + for (const [version, error] of [ + [ + "2.0.0", + /::error::The commit of v2\.0\.0 \([\da-f]{40}\) is not on main/u, + ], + ["3.0.0", /::error::The tag v3\.0\.0 does not exist/u], + ]) { + const other = checkout(); + const head = git(other, "rev-parse", "HEAD"); + const { status, stdout } = run(check, other, { VERSION: version }); + equal(status, 1); + match(stdout, error); + equal(git(other, "rev-parse", "HEAD"), head); + } + }); + + it("creates the GitHub Release only when the API answers 404", () => { + const create = script( + "recover", + "Create the GitHub Release when it is missing", + ); + const clone = checkout(); + git(clone, "checkout", "-q", "--detach", "v1.1.0"); + for (const [answer, status, created] of [ + ["200", 0, false], + ["404", 0, true], + ["403", 1, false], + ["500", 1, false], + ["4040", 1, false], + ["none", 1, false], + ]) { + rmSync(join(dir, "gh.log"), { force: true }); + const result = run(create, clone, { + VERSION: "1.1.0", + FAKE_GH_STATUS: answer, + }); + equal(result.status, status, answer); + equal(ghCalls().includes("release create"), created, answer); + if (created) { + match( + ghCalls(), + /^release create v1\.1\.0 --verify-tag --title v1\.1\.0 --generate-notes --notes-start-tag v1\.0\.0$/mu, + ); + } else if (status !== 0) { + match( + result.stdout, + /::error::Cannot tell whether the GitHub Release v1\.1\.0 exists/u, + ); + } + } + }); + }, +); From 186b49e4b2fb15ff4096addb1b19095ff9f258dc Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:47:48 +0300 Subject: [PATCH 137/675] ci: audit the dependencies and their signatures Nothing checked the registry signatures of the installed packages, and a vulnerable dependency of the published packages did not fail CI. The `lint`, `release` and `recover` jobs now run `npm audit signatures` after `npm ci`, and `lint` fails on any advisory of high severity or above in the production dependencies (`npm audit --omit=dev --audit-level=high`), as `npm run ci:all` does. The README records the disposition of every advisory of the full report: all of them are in the release tooling, and none is in the published packages. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 15 +++++++++++++++ README.md | 34 +++++++++++++++++++++++++++++----- package.json | 2 +- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04a763c9..a00cff15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,15 @@ jobs: - name: Install dependencies and build the workspaces run: npm ci + # The registry signatures (and provenance attestations) of the installed packages + - name: Signatures of the dependencies + run: npm audit signatures + + # The dependencies of the published packages. The advisories of the development + # dependencies are reviewed in the README ("Dependencies"). + - name: Audit of the production dependencies + run: npm audit --omit=dev --audit-level=high + - name: Lockstep versions run: npm run release:check @@ -191,6 +200,9 @@ jobs: - name: Install dependencies and build the workspaces run: npm ci + - name: Signatures of the dependencies + run: npm audit signatures + # Tags the release, publishes every workspace with the same version (see # scripts/release.mjs: `version`, `pack` into RELEASE_DIR, then `publish` of these # archives once the registry is checked for all of them) and creates the GitHub Release, @@ -275,6 +287,9 @@ jobs: - name: Install dependencies and build the workspaces run: npm ci + - name: Signatures of the dependencies + run: npm audit signatures + # Packs the archives again (the same ones, bit for bit, as the release job), checks the # registry for all of them, then publishes the missing ones with provenance - name: Publish the packages that are not published yet diff --git a/README.md b/README.md index 170e5618..9d40054f 100644 --- a/README.md +++ b/README.md @@ -66,11 +66,35 @@ Dependencies are kept at their latest versions, except: notes with `conventional-changelog-writer` 8, and the version 10 of the preset requires the version 9 of the writer (the release fails). -The published packages have no known vulnerabilities (`npm audit --omit=dev`). The advisories of the -development dependencies come from the release tooling: `braces` (through `micromatch`, used by -semantic-release) has no fixed version, and the other ones are bundled with npm 11.21.0, the latest -version that `@semantic-release/npm` supports (bundled dependencies cannot be updated separately). -They do not affect the published packages. +### Audit + +CI checks the registry signatures (and provenance attestations) of every installed package +(`npm audit signatures`), and fails on any advisory of high severity or above in the dependencies +of the published packages (`npm audit --omit=dev --audit-level=high`): there is none. + +The full `npm audit` (2026-10-14) reports 13 vulnerable packages (2 moderate, 11 high). All of them +are development dependencies of the release tooling, none is in the published packages, and each +one is accepted: + +- `braces` 3.0.3 (high: + [GHSA-vfj7-8cjw-p6xm](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm)) has no fixed version. + Through `micromatch` 4.0.8, semantic-release and its commit analyzer only expand the patterns of + `.releaserc.json` (the release branch and the release rules), never a pattern from outside of the + repository, and the advisory needs a deeply nested pattern. +- The other ones are bundled with `npm` 11.21.0, which is never run: `brace-expansion` 5.0.9 (high: + GHSA-q2hr-2g5m-vwhr, GHSA-qhr7-859c-m2p7, GHSA-6j4f-fj2g-mc7p), `http-cache-semantics` 4.2.0 + (high: GHSA-ch52-4w7c-c8xp), `undici` 6.28.0 (high: GHSA-3wwx-pv8p-q78v, GHSA-r53p-7pc4-xj5r, + GHSA-rfgv-xxqx-mfg5), `ip-address` 10.5.0 (moderate: GHSA-rpw4-54j3-4h4q, GHSA-2vr4-cq9g-pvrc, + GHSA-j6r3-76f7-8jcv, GHSA-h3mg-xc3c-68pw) and `postcss-selector-parser` 7.1.4 (moderate: + GHSA-rj75-hqrm-r3gf). `npm` is a dependency of `@semantic-release/npm`, which semantic-release + always installs; 11.21.0 is the latest npm 11, the version that `@semantic-release/npm` supports, + and the bundled dependencies of npm cannot be updated separately. This repository does not use + the `@semantic-release/npm` plugin: the release publishes with the npm of its runtime (11.13.0, + with Node.js 24.16.0). +- The 7 other packages (`micromatch`, `semantic-release`, `@semantic-release/exec`, + `@semantic-release/commit-analyzer`, `@semantic-release/release-notes-generator`, + `@semantic-release/github` and `@semantic-release/npm`) are reported only because they depend on + `braces` or on `npm`. ## Known issues diff --git a/package.json b/package.json index beed1749..8a9e160f 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ }, "scripts": { "build": "npm run build --workspaces", - "ci:all": "npm ci && npm run release:check && npm run prettier && npm run commitlint && npm run eslint && npm run test:tooling && npm run coverage && npm run pack:check && npm run consumer:check && npm run release:rehearsal && npm run docs:build", + "ci:all": "npm ci && npm audit signatures && npm audit --omit=dev --audit-level=high && npm run release:check && npm run prettier && npm run commitlint && npm run eslint && npm run test:tooling && npm run coverage && npm run pack:check && npm run consumer:check && npm run release:rehearsal && npm run docs:build", "commitlint": "commitlint --verbose --from=origin/main", "consumer:check": "node scripts/consumer-check.mjs", "coverage": "c8 npm test --", From 9d7b6f3ec6eec8bc32e745ee0939e214a56e7c88 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:48:29 +0300 Subject: [PATCH 138/675] ci: add CodeQL, a dependency review and the tests on macOS CodeQL analyzes the JavaScript and the TypeScript of every pull request and of `main`, and the dependency review rejects a pull request that adds a dependency with a known vulnerability. The tests also run on macOS (Node.js 24), whose job the release and the docs wait for, and the deployment of the docs has a timeout. Dependabot groups its updates (production and development dependencies, actions) and ignores the versions held back on purpose: TypeScript 7, `@types/node` 25 and later (the types follow the oldest supported Node.js), and `conventional-changelog-conventionalcommits` 10. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 21 ++++++++++++++++- .github/workflows/ci.yml | 44 ++++++++++++++++++++++++++++++++++-- .github/workflows/codeql.yml | 41 +++++++++++++++++++++++++++++++++ README.md | 3 +++ 4 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 18b51b19..9da51927 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,10 +7,25 @@ updates: interval: daily allow: - dependency-type: direct - # Workspace packages are released in lockstep by scripts/release.mjs + # One pull request for the dependencies of the published packages, one for the tooling + groups: + production: + dependency-type: production + development: + dependency-type: development ignore: + # Workspace packages are released in lockstep by scripts/release.mjs - dependency-name: binden - dependency-name: "@binden/*" + # Held back on purpose (see "Dependencies" in the README): the tooling does not + # support TypeScript 7 yet, the types follow the oldest supported Node.js (24), and + # the version 10 of the preset breaks the release notes of semantic-release 25 + - dependency-name: typescript + versions: [">=7"] + - dependency-name: "@types/node" + versions: [">=25"] + - dependency-name: conventional-changelog-conventionalcommits + versions: [">=10"] assignees: - vansergen versioning-strategy: increase @@ -25,6 +40,10 @@ updates: directory: / schedule: interval: daily + groups: + actions: + patterns: + - "*" assignees: - vansergen labels: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a00cff15..ed384252 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,10 +152,48 @@ jobs: - name: Tooling tests (release scripts, test runner) run: npm run test:tooling + # The tests on macOS (its sockets, file system and timers) + macos: + if: github.event_name != 'workflow_dispatch' + + runs-on: macos-latest + + timeout-minutes: 20 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + check-latest: true + cache: npm + + - name: Install dependencies and build the workspaces + run: npm ci + + - name: Tests + run: npm test + + # The dependencies that a pull request adds or updates, against the advisory database + dependency-review: + if: github.event_name == 'pull_request' + + runs-on: ubuntu-latest + + timeout-minutes: 10 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + release: if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: [lint, test, windows] + needs: [lint, test, windows, macos] runs-on: ubuntu-latest @@ -330,10 +368,12 @@ jobs: # The documentation artifact is uploaded by the `lint` job. The docs of `main` are # deployed even when no release is due (or the release fails). - needs: [lint, test, windows] + needs: [lint, test, windows, macos] runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: pages: write id-token: write diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..ab555e96 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + # The sources of the packages, their tests and the tooling (JavaScript and TypeScript need + # no build to be analyzed) + analyze: + name: Analyze (javascript-typescript) + + runs-on: ubuntu-latest + + timeout-minutes: 30 + + permissions: + contents: read + security-events: write + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + with: + languages: javascript-typescript + build-mode: none + + - uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + with: + category: /language:javascript-typescript diff --git a/README.md b/README.md index 9d40054f..5403ef34 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,9 @@ Dependencies are kept at their latest versions, except: notes with `conventional-changelog-writer` 8, and the version 10 of the preset requires the version 9 of the writer (the release fails). +Dependabot does not propose these versions (TypeScript 7 and later, `@types/node` 25 and later, and +the preset 10 and later: see `ignore` in [`.github/dependabot.yml`](.github/dependabot.yml)). + ### Audit CI checks the registry signatures (and provenance attestations) of every installed package From 13143cef817d00b2aeeeeb548e6aea76a680da5e Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Wed, 14 Oct 2026 23:44:13 +0300 Subject: [PATCH 139/675] feat!: add request deadlines and bound the error handler in binden A request could hang forever: a middleware that never settles kept its socket, and so did a custom error handler. The new `timeout` option (off by default) and `context.setTimeout()` give every request a deadline. Once it has passed, the signal of the request is aborted, the stack stops, the late result of the running middleware is ignored (a late failure is logged at debug, never an unhandled rejection) and a `BindenError(503)` with the code `deadline` goes to the error handler, or the connection is closed when the reply has started. The framework then seals the exchange: only its own reply may answer, so a late `context.send()` of a middleware does nothing instead of racing the error reply. The error handler is bounded by `error_handler_timeout` (5000 ms): a handler that hangs, throws or rejects gets an opaque 500 without the framing and representation headers it may have set. BindenError gets a `code` for such machine-readable reasons. BREAKING CHANGE: a custom `error_handler` that throws or rejects no longer closes the connection: the client gets an empty 500 (the connection is only closed once its reply has started). A handler that does not settle within `error_handler_timeout` (5000 ms by default) is treated the same way. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 35 ++- packages/binden/src/binden.ts | 282 ++++++++++++++++-- packages/binden/src/context.ts | 39 ++- packages/binden/src/error.ts | 23 +- packages/binden/src/exchange.ts | 56 ++++ packages/binden/test/binden.test.ts | 47 --- packages/binden/test/error.test.ts | 12 + packages/binden/test/exchange.test.ts | 65 +++++ packages/binden/test/fixtures/hangs.ts | 78 +++++ packages/binden/test/helpers.ts | 159 ++++++++++ packages/binden/test/lifecycle.test.ts | 388 +++++++++++++++++++++++++ packages/binden/test/process.test.ts | 8 + 12 files changed, 1101 insertions(+), 91 deletions(-) create mode 100644 packages/binden/test/fixtures/hangs.ts create mode 100644 packages/binden/test/helpers.ts create mode 100644 packages/binden/test/lifecycle.test.ts diff --git a/packages/binden/README.md b/packages/binden/README.md index dc2eab71..7ddcdb86 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -15,7 +15,9 @@ npm install binden `new Binden(options)` takes the following options: - `auto_head` (default `true`) - a router without `HEAD` middlewares handles `HEAD` with its `GET` middlewares (see [Router](#router)) -- `error_handler` - replaces the default reply to an error thrown by a middleware (the thrown value may be anything, also `null`). It may be `async`: it is awaited, and it must have ended its reply when it settles. Nothing started then gets the default reply, while a reply that is not ended (e.g. a `write()` without `end()`) is cut by closing the connection. When it fails, the connection is closed. There is no deadline: a handler that never settles keeps its request. +- `error_handler` - replaces the default reply to an error thrown by a middleware (the thrown value may be anything, also `null`). It may be `async`: it is awaited, and it must have ended its reply when it settles. Nothing started then gets the default reply, while a reply that is not ended (e.g. a `write()` without `end()`) is cut by closing the connection. A handler that throws, rejects or does not settle within `error_handler_timeout` gets an empty `500 Internal Server Error` instead (without the framing and representation headers it may have set), or the connection is closed when its reply has started; its later reply is ignored, and its later failure is logged (`debug`). +- `error_handler_timeout` (default `5000`) - the milliseconds the `error_handler` has to settle (an integer from 1 to 2³¹−1) +- `timeout` (default `0`, none) - the deadline of every request in milliseconds (an integer from 0 to 2³¹−1, see [Lifecycle](#lifecycle)) - `logger` - see [Logging](#logging) - `trust_proxy`, `allowed_hosts`, `request_id`, `encoded_slashes`, `query` - the options of the requests, see [Request options](#request-options) @@ -93,6 +95,37 @@ const debugApp = new Binden({ logger: verbose }); Likewise, `error` can be replaced with a serializer that keeps the details of the errors (e.g. `stdSerializers.err` of [pino](https://getpino.io/#/docs/api?id=pino-stdserializers)). +### Lifecycle + +Every request has an abort signal, `context.signal`, aborted at most once (its `reason` is a `BindenAbortError`, whose `reason` says why): + +- `client_closed` - the connection has closed before the response has finished; +- `deadline` - the deadline of the request has passed; +- `shutdown` - the server is shutting down, and its drain time has passed. + +The deadline of a request is the `timeout` option of `Binden` (none by default). A middleware restarts it from now with `context.setTimeout(ms)`, or removes it with `context.setTimeout(0)` (e.g. for a stream of server-sent events). Once the deadline has passed: + +- the stack stops: no other middleware runs, and the result of the running one is ignored (a later failure is logged at `debug`, and its later replies through the context, such as `context.send()`, do nothing); +- a request whose reply has not started gets a `BindenError(503, { code: "deadline" })`, which goes to the error handler; +- a reply that has started (or a response that has not finished once the stack has completed) is cut by closing the connection. + +`context.onCleanup(fn)` runs `fn` once the response is closed (finished, or its connection closed), after the functions registered later; a failure is logged. + +```typescript +import { Binden, type Context } from "binden"; + +const app = new Binden({ timeout: 30_000 }).use(async (context: Context) => { + // Stops waiting once the client has gone away or the deadline has passed + const upstream = await fetch("https://example.com/", { + signal: context.signal, + }); + context.onCleanup(() => { + context.log.debug("The request is complete"); + }); + await context.text(await upstream.text()); +}); +``` + ### Context - `.log` - get the logger (see [`@binden/logger`](https://github.com/binden-js/binden/tree/main/packages/logger)) diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index 7405342e..ac2c9c27 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -4,6 +4,7 @@ import { isDeepStrictEqual } from "node:util"; import type { ILogger } from "@binden/logger"; import { Context } from "./context.js"; import { BindenError } from "./error.js"; +import { BindenAbortError, Exchange } from "./exchange.js"; import defaultLogger from "./logger.js"; import { Middleware, type IMiddleware } from "./middleware.js"; import { property } from "./property.js"; @@ -25,6 +26,21 @@ export interface IBindenOptions { auto_head?: boolean; /** Replaces the default reply to an error, unless it does not reply itself */ error_handler?: (context: Context, error: unknown) => Promise | void; + /** + * The time in milliseconds that the `error_handler` has to settle (`5000`): + * a handler that takes longer, throws or rejects gets an empty + * `500 Internal Server Error` instead (or the connection is closed, once + * the reply has started) + */ + error_handler_timeout?: number | undefined; + /** + * The deadline of every request in milliseconds (`0`, the default, for + * none): once it has passed, `context.signal` is aborted, the stack stops + * and a `503 Service Unavailable` error (`code: "deadline"`) is replied (or + * the connection is closed, once the reply has started). A middleware may + * change it with `context.setTimeout()`. + */ + timeout?: number | undefined; /** The logger of the contexts (the default one is set up by `BINDEN_LOG_LEVEL`) */ logger?: ILogger; /** @@ -35,18 +51,31 @@ export interface IBindenOptions { trust_proxy?: ITrustProxy; } +/** The greatest delay of a Node.js timer */ +const MAX_DELAY = 2 ** 31 - 1; + +/** Thrown through the stack once the request has been aborted: the stack stops */ +const ABORTED = new Error("The request has been aborted"); + +/** The outcome of an `error_handler` that has not settled in time */ +const EXPIRED = Symbol("expired"); + export class Binden { readonly #stack: IStack[]; readonly #auto_head: boolean; readonly #error_handler: ((context: Context, error: unknown) => Promise | void) | null; + readonly #error_handler_timeout: number; + readonly #timeout: number; readonly #logger: ILogger; readonly #Request: typeof BindenRequest; public constructor({ error_handler, + error_handler_timeout = 5000, auto_head = true, logger = defaultLogger, + timeout = 0, trust_proxy = false, }: IBindenOptions = {}) { if ( @@ -55,8 +84,12 @@ export class Binden { ) { throw new RangeError("`trust_proxy` is not a non-negative integer"); } + Binden.#delay("timeout", timeout, 0); + Binden.#delay("error_handler_timeout", error_handler_timeout, 1); this.#stack = []; this.#error_handler = error_handler ?? null; + this.#error_handler_timeout = error_handler_timeout; + this.#timeout = timeout; this.#auto_head = auto_head; this.#logger = logger; // The requests of this application trust its proxies @@ -207,19 +240,67 @@ export class Binden { response: BindenResponse, ): void { const context = new Context({ request, response, logger: this.#logger }); - this.#handle(context) - .catch(async (error: unknown) => this.#errorHandler(context, error)) - .catch((error: unknown) => { - // The error handler failed (e.g. a custom `error_handler` threw): the - // response cannot be completed, but the process must not crash - const extra = { context, error }; - Binden.#log(context, "error", "The error handler failed", extra); - request.destroy(); - }); + const exchange = Exchange.for(request, response); + if (this.#timeout > 0) { + exchange.setTimeout(this.#timeout); + } + this.#run(context, exchange).catch((error: unknown) => { + // Unexpected (every failure is handled): the process must not crash + Binden.#log(context, "error", "The request has failed", { error }); + response.destroy(); + }); } - async #handle(context: Context): Promise { - let next = context; + /** + * Runs the stack, then the error handler on a failure or a deadline. A + * deadline that passes later (e.g. while a stream is sent) closes the + * connection of a response that has not finished. + */ + async #run(first: Context, exchange: Exchange): Promise { + const run = { context: first }; + let failure: { error: unknown } | null = null; + let stopped = false; + try { + await this.#handle(run, exchange); + } catch (error: unknown) { + if (error === ABORTED) { + stopped = true; + } else { + failure = { error }; + } + } + + const { context } = run; + const { signal } = exchange; + if (stopped || (failure && signal.aborted)) { + // A failure once aborted is a consequence (e.g. of the client going away) + await this.#aborted(context, exchange); + return; + } else if (failure) { + await this.#errorHandler(context, exchange, failure.error); + } + + if (!signal.aborted) { + signal.addEventListener( + "abort", + () => { + const { reason } = signal as { reason: BindenAbortError }; + if ( + reason.reason === "deadline" && + !context.response.writableFinished + ) { + const message = "The deadline has passed. Destroying the socket"; + Binden.#log(context, "debug", message, {}); + context.response.destroy(); + } + }, + { once: true }, + ); + } + } + + async #handle(run: { context: Context }, exchange: Exchange): Promise { + let next = run.context; // The methods of the guarded routers that have refused the request const allow = new Set(); @@ -258,7 +339,8 @@ export class Binden { const methodMiddlewares = refused ? [] : middlewares(m); for (const mw of methodMiddlewares) { - next = await Binden.#runMiddleware(mw, next); + next = await Binden.#runMiddleware(mw, next, exchange); + run.context = next; if (next.done) { await Binden.#complete(next); @@ -266,7 +348,8 @@ export class Binden { } } } else { - next = await Binden.#runMiddleware(item, next); + next = await Binden.#runMiddleware(item, next, exchange); + run.context = next; if (next.done) { await Binden.#complete(next); @@ -288,6 +371,31 @@ export class Binden { throw new BindenError(404); } + /** + * The request has been aborted while the stack ran (the stack has stopped): + * after the deadline, a `503` error is replied through the error handler + * when nothing has started, and the connection is closed otherwise; a client + * that has gone away (or a shutdown) gets nothing + */ + async #aborted(context: Context, exchange: Exchange): Promise { + const { response } = context; + const { reason } = exchange.signal as { reason: BindenAbortError }; + if (reason.reason === "deadline" && !response.started) { + // The middlewares that still run may no longer reply + exchange.seal(); + const error = new BindenError(503, { code: "deadline" }); + await exchange.reply(async () => { + await this.#errorHandler(context, exchange, error); + }); + return; + } + if (!response.writableFinished) { + const message = "The request has been aborted. Destroying the socket"; + Binden.#log(context, "debug", message, { reason: reason.reason }); + response.destroy(); + } + } + /** A middleware has set `done`: a response that nothing has started is sent empty */ static async #complete(context: Context): Promise { if (!context.response.started) { @@ -297,17 +405,41 @@ export class Binden { } } - /** Error handler: the provided `error_handler` (awaited), then the default reply if needed */ - async #errorHandler(context: Context, error: unknown): Promise { + /** + * Error handler: the provided `error_handler` (awaited within + * `error_handler_timeout`), then the default reply if needed + */ + async #errorHandler( + context: Context, + exchange: Exchange, + error: unknown, + ): Promise { if (this.#error_handler) { const base = { context, error }; const message = "Passing error to the provided `error_handler`"; Binden.#log(context, "trace", message, base); - await this.#error_handler(context, error); - - const { request, response } = context; - if (response.writableEnded) { + const outcome = await this.#bounded(this.#error_handler, context, error); + + const { response } = context; + if (outcome !== null) { + // The handler has failed, or has not settled in time: it may no + // longer reply, and the request gets an opaque error + exchange.seal(); + const failed = + outcome === EXPIRED + ? "The provided `error_handler` has not settled in time" + : "The provided `error_handler` has failed"; + const extra = + outcome === EXPIRED ? { context } : { context, error: outcome.error }; + Binden.#log(context, "error", failed, extra); + if (response.started) { + response.destroy(); + } else { + await exchange.reply(async () => Binden.#opaque(context)); + } + return; + } else if (response.writableEnded) { return; } else if (response.started) { // A reply that is not ended once the handler has settled (e.g. a @@ -320,7 +452,7 @@ export class Binden { `${unfinished}. Destroying the socket`, base, ); - request.destroy(); + response.destroy(); return; } const ignored = "The provided `error_handler` has not replied"; @@ -329,6 +461,58 @@ export class Binden { await Binden.#defaultErrorHandler(context, error); } + /** + * Awaits the `error_handler` for `error_handler_timeout` at most: `null` + * once it has settled, `EXPIRED` once the time has passed (its result is + * then ignored, and a later failure logged) and its error once it has failed + */ + async #bounded( + handler: (context: Context, error: unknown) => Promise | void, + context: Context, + error: unknown, + ): Promise { + let expired = false; + const settled = (async (): Promise => { + await handler(context, error); + })().then( + () => null, + (failure: unknown) => { + if (expired) { + const late = "The provided `error_handler` has failed late"; + Binden.#log(context, "debug", late, { context, error: failure }); + } + return { error: failure }; + }, + ); + const { promise: timeout, resolve } = + Promise.withResolvers(); + const timer = setTimeout(resolve, this.#error_handler_timeout, EXPIRED); + timer.unref(); + const outcome = await Promise.race([settled, timeout]); + clearTimeout(timer); + expired = outcome === EXPIRED; + return outcome; + } + + /** + * An empty `500 Internal Server Error`, without the headers of the body that + * a failed handler may have set + */ + static async #opaque(context: Context): Promise { + const { response } = context; + for (const name of [...Binden.#representation, "Content-Type"]) { + response.removeHeader(name); + } + response.status(DefaultErrorCode); + try { + await response.send(); + } catch { + Binden.#log(context, "debug", "The error reply has not been sent", { + context, + }); + } + } + /** * Default reply to an error, which may be any thrown value: its properties * are read without trusting them (e.g. `throw null`, or a throwing getter) @@ -399,6 +583,7 @@ export class Binden { static async #runMiddleware( middleware: IMiddleware, context: Context, + exchange: Exchange, ): Promise { const { name } = middleware.constructor; @@ -408,10 +593,14 @@ export class Binden { } try { - const next = + const result = typeof middleware === "function" - ? await middleware(context) - : await middleware.run(context); + ? middleware(context) + : middleware.run(context); + const next = + typeof property(result, "then") === "function" + ? await Binden.#race(result, context, exchange) + : result; if (!(next instanceof Context)) { return context; } else if ( @@ -426,7 +615,9 @@ export class Binden { } return next; } catch (error: unknown) { - if (middleware instanceof Middleware && middleware.ignore_errors) { + if (error === ABORTED) { + throw error; + } else if (middleware instanceof Middleware && middleware.ignore_errors) { const extra = { error, name }; Binden.#log(context, "debug", "Middleware ignores errors", extra); return context; @@ -436,6 +627,38 @@ export class Binden { } } + /** + * The result of a middleware, unless the request is aborted first: its late + * result is then ignored (and a late failure logged) + */ + static async #race( + result: unknown, + context: Context, + exchange: Exchange, + ): Promise { + const settled = Promise.resolve(result).then( + (value: unknown) => ({ ok: true, value }) as const, + (error: unknown) => { + if (exchange.signal.aborted) { + const message = + "A middleware has failed after the request was aborted"; + Binden.#log(context, "debug", message, { context, error }); + } + return { ok: false, error } as const; + }, + ); + const outcome = await Promise.race([ + settled, + exchange.aborted.then(() => null), + ]); + if (outcome === null) { + throw ABORTED; + } else if (!outcome.ok) { + throw outcome.error; + } + return outcome.value; + } + /** The headers of a body, which do not apply to an error that replaces it */ static readonly #representation = [ "Content-Disposition", @@ -469,6 +692,17 @@ export class Binden { return new RegExp(path.source, path.flags.replaceAll(/[gy]/gu, "")); } + /** A delay of a timer: an integer from `min` to the greatest delay of Node.js */ + static #delay(name: string, value: unknown, min: number): void { + if (typeof value !== "number" || !Number.isInteger(value)) { + throw new TypeError(`\`${name}\` is not an integer`); + } else if (value < min || value > MAX_DELAY) { + throw new RangeError( + `\`${name}\` is not between ${min} and ${MAX_DELAY}`, + ); + } + } + static #validateItem(item: unknown): item is IStackItem { return ( item instanceof Middleware || diff --git a/packages/binden/src/context.ts b/packages/binden/src/context.ts index 21dc7ebb..aafb01fa 100644 --- a/packages/binden/src/context.ts +++ b/packages/binden/src/context.ts @@ -144,45 +144,52 @@ export class Context< public async send( data?: Buffer | Readable | bigint | number | string, ): Promise { - await this.response.send(data); - this.done = true; + await this.#reply(() => this.response.send(data)); } public async json( data: Record | unknown[], stringify?: (input: Record | unknown[]) => string, ): Promise { - if (typeof stringify === "undefined") { - await this.response.json(data); - } else { - await this.response.json(data, stringify); - } - this.done = true; + await this.#reply(async () => { + await (typeof stringify === "undefined" + ? this.response.json(data) + : this.response.json(data, stringify)); + }); } public async text(data: string): Promise { - await this.response.text(data); - this.done = true; + await this.#reply(() => this.response.text(data)); } public async html(data: string): Promise { - await this.response.html(data); - this.done = true; + await this.#reply(() => this.response.html(data)); } public async form(data: URLSearchParams): Promise { - await this.response.form(data); - this.done = true; + await this.#reply(() => this.response.form(data)); } /** Send a file */ public async sendFile(url: URL | string): Promise { - await this.response.sendFile(url); - this.done = true; + await this.#reply(() => this.response.sendFile(url)); } // eslint-disable-next-line class-methods-use-this public throw(status: number, opts?: IBindenErrorOptions): never { throw new BindenError(status, opts); } + + /** + * Sends a reply and sets `done`, unless the framework has taken over the + * reply (e.g. the deadline has passed): a late reply is then ignored + */ + async #reply(send: () => Promise): Promise { + if (!this.#exchange.may_reply) { + this.log.debug("A late reply has been ignored"); + return; + } + await send(); + this.done = true; + } } diff --git a/packages/binden/src/error.ts b/packages/binden/src/error.ts index de4e7202..da4862cd 100644 --- a/packages/binden/src/error.ts +++ b/packages/binden/src/error.ts @@ -1,12 +1,20 @@ import { STATUS_CODES } from "node:http"; +/** The options of a `BindenError` */ export interface IBindenErrorOptions extends ErrorOptions { - json?: Record; - message?: string; - expose?: boolean; + /** Sent as the body when `expose` is `true` (as JSON, instead of `message`) */ + json?: Record | undefined; + /** Sent as the body when `expose` is `true` and there is no `json` */ + message?: string | undefined; + /** Whether the default error handler sends `json` or `message` (`false`) */ + expose?: boolean | undefined; + /** A machine-readable reason (e.g. `deadline`), not sent to the client */ + code?: string | undefined; } +/** An HTTP error: its status code (400 to 599) is the status of the reply */ export class BindenError extends Error { + readonly #code: string | null; readonly #expose: boolean; readonly #json: Record | null; readonly #status: number; @@ -18,6 +26,7 @@ export class BindenError extends Error { expose = false, json, cause, + code, }: IBindenErrorOptions = {}, ) { if (!Number.isInteger(status)) { @@ -26,6 +35,8 @@ export class BindenError extends Error { throw new RangeError("Status code is greater than 599"); } else if (status < 400) { throw new RangeError("Status code is less than 400"); + } else if (typeof code !== "undefined" && typeof code !== "string") { + throw new TypeError("`code` is not a string"); } if (typeof cause === "undefined") { @@ -35,11 +46,17 @@ export class BindenError extends Error { } this.name = "BindenError"; + this.#code = code ?? null; this.#expose = Boolean(expose); this.#json = json ?? null; this.#status = status; } + /** The machine-readable reason of the error, if any */ + public get code(): string | null { + return this.#code; + } + public get expose(): boolean { return this.#expose; } diff --git a/packages/binden/src/exchange.ts b/packages/binden/src/exchange.ts index c605eee4..6caa2d9f 100644 --- a/packages/binden/src/exchange.ts +++ b/packages/binden/src/exchange.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import type { IncomingMessage, ServerResponse } from "node:http"; /** Why the work of a request has been abandoned */ @@ -34,6 +35,9 @@ const MAX_DELAY = 2 ** 31 - 1; const exchanges = new WeakMap(); +/** The reply (see `Exchange.reply()`) that the current asynchronous work belongs to */ +const replies = new AsyncLocalStorage(); + /** * The state of one request and its response, shared by every context created * for them (e.g. a middleware that returns a new context of the same request): @@ -46,7 +50,10 @@ export class Exchange { readonly #cleanups: (() => unknown)[]; readonly #finished: Promise; readonly #report: IExchangeReport; + readonly #aborted: Promise; #phase: IPhase; + #sealed: boolean; + #reply: object | null; #done: boolean; #timer: NodeJS.Timeout | null; #params: Readonly>; @@ -62,7 +69,18 @@ export class Exchange { this.#state = state; this.#cleanups = []; this.#report = report ?? Exchange.#ignore; + this.#aborted = new Promise((resolve) => { + this.#controller.signal.addEventListener( + "abort", + () => { + resolve(); + }, + { once: true }, + ); + }); this.#phase = "running"; + this.#sealed = false; + this.#reply = null; this.#done = false; this.#timer = null; this.#params = Object.freeze({}); @@ -101,6 +119,11 @@ export class Exchange { return found; } + /** The exchange of a request, if it has one */ + public static find(request: IncomingMessage): Exchange | null { + return exchanges.get(request) ?? null; + } + public get signal(): AbortSignal { return this.#controller.signal; } @@ -149,6 +172,39 @@ export class Exchange { return this.#phase; } + /** Settles once the signal is aborted (never rejects) */ + public get aborted(): Promise { + return this.#aborted; + } + + /** + * Whether the current asynchronous work may reply: anything before the + * exchange is sealed, then only the work run by the latest `reply()` + */ + public get may_reply(): boolean { + return ( + !this.#sealed || + (this.#reply !== null && replies.getStore() === this.#reply) + ); + } + + /** + * From now on only the framework replies (e.g. once the deadline has + * passed): the work of the middlewares, and of any earlier `reply()`, may + * no longer reply + */ + public seal(): void { + this.#sealed = true; + this.#reply = null; + } + + /** Runs the reply of the framework: the work it starts may reply (see `may_reply`) */ + public reply(fn: () => T): T { + const reply = {}; + this.#reply = reply; + return replies.run(reply, fn); + } + /** Settles once the response is closed and every cleanup has run */ public get finished(): Promise { return this.#finished; diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index bc22bde3..d99f5f59 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -518,53 +518,6 @@ suite("Binden", () => { } }); - test("closes the connection when an async error handler fails", async () => { - const logged = await loggedApp({ - error_handler: async (): Promise => { - await new Promise((resolve) => { - setTimeout(resolve, 20); - }); - throw new Error("A failing error handler"); - }, - }); - try { - logged.app.use(() => { - throw new BindenError(400); - }); - - // The connection is closed (not left open until the deadline) - await rejects( - fetch(logged.url, { signal: AbortSignal.timeout(5000) }), - (error: Error) => error.name !== "AbortError", - ); - ok(logged.output().includes("The error handler failed"), logged.output()); - } finally { - await logged.close(); - } - }); - - test("closes the connection when the error handler fails", async () => { - const logged = await loggedApp({ - error_handler: (): never => { - throw new Error("A failing error handler"); - }, - }); - try { - logged.app.use(() => { - throw new BindenError(400); - }); - - // The connection is closed (not left open until the deadline) - await rejects( - fetch(logged.url, { signal: AbortSignal.timeout(5000) }), - (error: Error) => error.name !== "AbortError", - ); - ok(logged.output().includes("The error handler failed"), logged.output()); - } finally { - await logged.close(); - } - }); - test("replies 500 to any thrown value", async () => { const thrown: Partial unknown>> = { "/null": () => null, diff --git a/packages/binden/test/error.test.ts b/packages/binden/test/error.test.ts index cf145ce4..e14f9eaf 100644 --- a/packages/binden/test/error.test.ts +++ b/packages/binden/test/error.test.ts @@ -62,6 +62,18 @@ suite("BindenError", () => { } }); + test("constructor (`code`)", () => { + deepStrictEqual( + new BindenError(503, { code: "deadline" }).code, + "deadline", + ); + deepStrictEqual(new BindenError(503).code, null); + throws( + () => new BindenError(503, { code: 1 as unknown as string }), + new TypeError("`code` is not a string"), + ); + }); + test("constructor (an unregistered status code)", () => { const error = new BindenError(499); deepStrictEqual(error.status, 499); diff --git a/packages/binden/test/exchange.test.ts b/packages/binden/test/exchange.test.ts index e80d57b9..4cbd98b5 100644 --- a/packages/binden/test/exchange.test.ts +++ b/packages/binden/test/exchange.test.ts @@ -349,6 +349,71 @@ suite("Exchange", () => { ok(ran); }); + test("find() finds the exchange of a request only", async () => { + const [request, response] = await exchange(); + deepStrictEqual(Exchange.find(request), null); + const created = Exchange.for(request, response); + ok(Exchange.find(request) === created); + response.end(); + }); + + test("`aborted` settles once the signal is aborted", async () => { + const [request, response] = await exchange(); + const exchangeOf = Exchange.for(request, response); + let settled = false; + const aborted = exchangeOf.aborted.then(() => { + settled = true; + }); + await sleep(5); + ok(!settled); + exchangeOf.abort("deadline"); + await aborted; + ok(settled); + response.end(); + }); + + test("once sealed, only the work of the latest reply() may reply", async () => { + const [request, response] = await exchange(); + const exchangeOf = Exchange.for(request, response); + + // Not sealed: anything may reply + ok(exchangeOf.may_reply); + ok(exchangeOf.reply(() => exchangeOf.may_reply)); + + exchangeOf.seal(); + ok(!exchangeOf.may_reply); + + let resume: () => void = nothing; + const earlier = exchangeOf.reply(async () => { + ok(exchangeOf.may_reply); + await new Promise((resolve) => { + resume = resolve; + }); + return exchangeOf.may_reply; + }); + // The latest reply (and its asynchronous work) may reply + deepStrictEqual( + await exchangeOf.reply(async () => { + await sleep(1); + return exchangeOf.may_reply; + }), + true, + ); + // An earlier one no longer may + resume(); + deepStrictEqual(await earlier, false); + + // Sealed again: no reply may reply + const latest = exchangeOf.reply(async () => { + exchangeOf.seal(); + await sleep(1); + return exchangeOf.may_reply; + }); + deepStrictEqual(await latest, false); + ok(!exchangeOf.may_reply); + response.end(); + }); + test("abort() aborts once", async () => { const [request, response] = await exchange(); const exchangeOf = Exchange.for(request, response); diff --git a/packages/binden/test/fixtures/hangs.ts b/packages/binden/test/fixtures/hangs.ts new file mode 100644 index 00000000..01c2bb61 --- /dev/null +++ b/packages/binden/test/fixtures/hangs.ts @@ -0,0 +1,78 @@ +// Run by `process.test.ts`: hung middlewares and error handlers, and their late +// failures, must neither crash the process (`unhandledRejection`) nor keep a +// request without a reply +import { type IncomingMessage, request } from "node:http"; +import type { AddressInfo } from "node:net"; +import { once } from "node:events"; +import { Binden, BindenError, type Context } from "../../index.js"; + +process.on("unhandledRejection", () => { + process.stderr.write("unhandledRejection\n"); + process.exitCode = 1; +}); +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const sleep = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); +const never = (): Promise => + new Promise(() => { + // Never settles + }); + +const handlers: Partial Promise | void>> = { + "/handler-hangs": never, + "/handler-fails-late": async () => { + await sleep(80); + throw new Error("A late failure"); + }, + "/handler-throws": () => { + throw new Error("A failure"); + }, +}; + +const app = new Binden({ + timeout: 30, + error_handler_timeout: 30, + error_handler: async (context: Context): Promise => { + await handlers[context.url.pathname]?.(); + }, +}).use(async (context) => { + const { pathname } = context.url; + if (pathname === "/hangs") { + await never(); + } else if (pathname === "/fails-late") { + await sleep(80); + throw new Error("A late failure"); + } + throw new BindenError(400); +}); + +const server = app.createServer().listen(0); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const statuses: number[] = []; +for (const path of [ + "/hangs", + "/fails-late", + "/handler-hangs", + "/handler-fails-late", + "/handler-throws", +]) { + const response = await new Promise((resolve, reject) => { + request({ agent: false, path, port }, resolve).on("error", reject).end(); + }); + response.resume(); + await once(response, "end"); + statuses.push(response.statusCode ?? 0); +} + +// The late failures come after the replies +await sleep(150); +server.close(); +process.stdout.write(`${statuses.join(" ")}\n`); diff --git a/packages/binden/test/helpers.ts b/packages/binden/test/helpers.ts new file mode 100644 index 00000000..c80b15ad --- /dev/null +++ b/packages/binden/test/helpers.ts @@ -0,0 +1,159 @@ +import { once } from "node:events"; +import { + type IncomingHttpHeaders, + type OutgoingHttpHeaders, + request as httpRequest, + type Server, +} from "node:http"; +import type { AddressInfo } from "node:net"; +import { Logger } from "@binden/logger"; + +import { type Binden, serializers } from "../index.js"; + +/** Resolves after `ms` milliseconds */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** A promise that never settles (e.g. a middleware that hangs) */ +export function never(): Promise { + return new Promise(() => { + // Never settles + }); +} + +/** A logger whose records are kept */ +export interface ICaptured { + readonly logger: Logger; + /** The records written so far */ + readonly records: Record[]; + /** The records written so far, as written */ + output(): string; + /** Resolves once a record with this message has been written */ + waitFor(message: string, ms?: number): Promise>; +} + +/** A `Logger` (at `level`) whose records are kept */ +export function capture(level = "debug"): ICaptured { + const lines: string[] = []; + const logger = new Logger({ + destination: { + write(line: string): void { + lines.push(line); + }, + }, + level, + serializers, + }); + const records = (): Record[] => + lines.map((line) => JSON.parse(line) as Record); + return { + logger, + get records(): Record[] { + return records(); + }, + output: (): string => lines.join(""), + waitFor: async ( + message: string, + ms = 5000, + ): Promise> => { + const deadline = Date.now() + ms; + let found = records().find(({ msg }) => msg === message); + while (!found) { + if (Date.now() > deadline) { + throw new Error(`No record "${message}": ${lines.join("")}`); + } + await sleep(5); + found = records().find(({ msg }) => msg === message); + } + return found; + }, + }; +} + +/** A listening server of an application */ +export interface IListening { + readonly server: Server; + readonly port: number; + readonly url: string; + close(): Promise; +} + +/** Creates the server of the application and listens on a free port */ +export async function listen(app: Binden): Promise { + const server: Server = app.createServer(); + server.listen(0); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + return { + server, + port, + url: `http://localhost:${port}`, + close: async (): Promise => { + server.closeAllConnections(); + server.close(); + await once(server, "close"); + }, + }; +} + +/** A response, as received */ +export interface IReceived { + readonly status: number; + readonly headers: IncomingHttpHeaders; + readonly body: string; +} + +/** Sends a request (on a new connection) and reads the response within `ms` */ +export function send( + port: number, + { + path = "/", + method = "GET", + headers = {}, + body = null, + ms = 5000, + }: { + path?: string; + method?: string; + headers?: OutgoingHttpHeaders; + body?: string | null; + ms?: number; + } = {}, +): Promise { + return new Promise((resolve, reject) => { + const sent = httpRequest( + { + agent: false, + headers, + method, + path, + port, + signal: AbortSignal.timeout(ms), + }, + (response) => { + let received = ""; + response + .setEncoding("utf8") + .on("data", (chunk: string) => { + received += chunk; + }) + .once("end", () => { + resolve({ + status: response.statusCode ?? 0, + headers: response.headers, + body: received, + }); + }) + .once("error", reject); + }, + ); + sent.once("error", reject); + if (body !== null) { + sent.write(body); + } + sent.end(); + }); +} diff --git a/packages/binden/test/lifecycle.test.ts b/packages/binden/test/lifecycle.test.ts new file mode 100644 index 00000000..8394c467 --- /dev/null +++ b/packages/binden/test/lifecycle.test.ts @@ -0,0 +1,388 @@ +import { deepStrictEqual, ok, rejects, throws } from "node:assert/strict"; +import { Readable } from "node:stream"; +import { suite, test } from "node:test"; + +import { + Binden, + BindenAbortError, + BindenError, + type Context, + type IBindenOptions, +} from "../index.js"; +import { + capture, + type ICaptured, + type IListening, + listen, + never, + send, + sleep, +} from "./helpers.js"; + +/** Runs `fn` with a listening application (with a captured debug logger) */ +async function withApp( + options: IBindenOptions, + setup: (app: Binden) => void, + fn: (listening: IListening, captured: ICaptured) => Promise, +): Promise { + const captured = capture(); + const app = new Binden({ ...options, logger: captured.logger }); + setup(app); + const listening = await listen(app); + try { + await fn(listening, captured); + } finally { + await listening.close(); + } +} + +suite("Lifecycle", () => { + test("`timeout` and `error_handler_timeout` are validated", () => { + for (const timeout of [ + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + "10", + null, + ]) { + throws( + () => new Binden({ timeout: timeout as number }), + new TypeError("`timeout` is not an integer"), + ); + throws( + () => new Binden({ error_handler_timeout: timeout as number }), + new TypeError("`error_handler_timeout` is not an integer"), + ); + } + for (const timeout of [-1, 2 ** 31]) { + throws(() => new Binden({ timeout }), RangeError); + } + throws( + () => new Binden({ error_handler_timeout: 0 }), + new RangeError("`error_handler_timeout` is not between 1 and 2147483647"), + ); + ok( + new Binden({ timeout: 0, error_handler_timeout: 2 ** 31 - 1 }) instanceof + Binden, + ); + ok( + new Binden({ timeout: 2 ** 31 - 1, error_handler_timeout: 1 }) instanceof + Binden, + ); + }); + + test("a deadline stops the stack and replies `503` through the error handler", async () => { + const errors: unknown[] = []; + let reached = false; + await withApp( + { + timeout: 30, + error_handler: (context: Context, error: unknown): void => { + errors.push( + error, + (context.signal.reason as BindenAbortError).reason, + ); + }, + }, + (app) => { + app.use( + async () => { + await never(); + }, + () => { + reached = true; + }, + ); + }, + async ({ port }) => { + const response = await send(port); + deepStrictEqual([response.status, response.body], [503, ""]); + }, + ); + const [error, reason] = errors; + ok(error instanceof BindenError); + deepStrictEqual( + [error.status, error.code, reason], + [503, "deadline", "deadline"], + ); + ok(!reached); + }); + + test("a deadline closes the connection once the reply has started", async () => { + await withApp( + { timeout: 30 }, + (app) => { + app.use(async (context: Context) => { + // Chunked: an ended reply would look complete + context.response.writeHead(200); + context.response.write("abc"); + await never(); + }); + }, + async ({ port }) => { + await rejects(send(port), { code: "ECONNRESET" }); + }, + ); + }); + + test("the late result of a middleware is ignored", async () => { + let late = "pending"; + await withApp( + { timeout: 20 }, + (app) => { + app.use(async (context: Context) => { + await sleep(60); + // Resolves (without an error) + await context.send("late"); + late = "resolved"; + }); + }, + async ({ port }, captured) => { + const response = await send(port); + deepStrictEqual([response.status, response.body], [503, ""]); + await captured.waitFor("A late reply has been ignored"); + }, + ); + deepStrictEqual(late, "resolved"); + }); + + test("a late failure of a middleware is logged", async () => { + await withApp( + { timeout: 20 }, + (app) => { + app.use(async () => { + await sleep(60); + throw new Error("late"); + }); + }, + async ({ port }, captured) => { + const response = await send(port); + deepStrictEqual(response.status, 503); + await captured.waitFor( + "A middleware has failed after the request was aborted", + ); + }, + ); + }); + + test("a late reply does not replace the reply of the error handler", async () => { + await withApp( + { + timeout: 20, + error_handler: async (context: Context): Promise => { + // The late reply comes first + await sleep(80); + await context.status(503).send("handler"); + }, + }, + (app) => { + app.use(async (context: Context) => { + await sleep(40); + await context.send("late"); + }); + }, + async ({ port }) => { + const response = await send(port); + deepStrictEqual([response.status, response.body], [503, "handler"]); + }, + ); + }); + + test("`context.setTimeout()` sets the deadline of a request", async () => { + await withApp( + {}, + (app) => { + app.use("/deadline", async (context: Context) => { + context.setTimeout(20); + await never(); + }); + app.use("/none", async (context: Context) => { + context.setTimeout(20); + context.setTimeout(0); + await sleep(60); + await context.send("ok"); + }); + }, + async ({ port }) => { + deepStrictEqual((await send(port, { path: "/deadline" })).status, 503); + deepStrictEqual((await send(port, { path: "/none" })).body, "ok"); + }, + ); + }); + + test("a deadline closes a reply that has not finished once the stack has completed", async () => { + await withApp( + { timeout: 40 }, + (app) => { + app.use((context: Context) => { + // Never ends + new Readable({ + read(): void { + // Nothing more + }, + }).pipe(context.response); + context.response.write("abc"); + context.done = true; + }); + }, + async ({ port }) => { + await rejects(send(port), { code: "ECONNRESET" }); + }, + ); + }); + + test("an unexpected failure of the error handling closes the connection", async () => { + await withApp( + {}, + (app) => { + app.use((context: Context) => { + // The default reply then fails + context.response.removeHeader = (): never => { + throw new Error("Unexpected"); + }; + throw new BindenError(400); + }); + }, + async ({ port }, captured) => { + await rejects(send(port), { code: "ECONNRESET" }); + await captured.waitFor("The request has failed"); + }, + ); + }); + + suite("`error_handler_timeout`", () => { + test("an error handler that does not settle in time gets an opaque `500`", async () => { + await withApp( + { + error_handler_timeout: 20, + error_handler: async (context: Context): Promise => { + context.response.status(418).set({ + "Content-Type": "text/html", + "Content-Length": 99, + ETag: '"etag"', + "X-Kept": "1", + }); + await never(); + }, + }, + (app) => { + app.use(() => { + throw new BindenError(400); + }); + }, + async ({ port }, captured) => { + const response = await send(port); + deepStrictEqual([response.status, response.body], [500, ""]); + ok(!("content-type" in response.headers)); + ok(!("etag" in response.headers)); + deepStrictEqual(response.headers["content-length"], "0"); + deepStrictEqual(response.headers["x-kept"], "1"); + await captured.waitFor( + "The provided `error_handler` has not settled in time", + ); + }, + ); + }); + + test("an error handler that fails gets an opaque `500`", async () => { + const handlers: Partial Promise | void>> = { + "/throws": (): never => { + throw new Error("A failing error handler"); + }, + "/rejects": async (): Promise => { + await sleep(10); + throw new Error("A failing error handler"); + }, + }; + await withApp( + { + error_handler: async (context: Context): Promise => { + context.response.setHeader("Content-Type", "text/html"); + await handlers[context.url.pathname]?.(); + }, + }, + (app) => { + app.use(() => { + throw new BindenError(400); + }); + }, + async ({ port }, captured) => { + for (const path of Object.keys(handlers)) { + const response = await send(port, { path }); + deepStrictEqual([response.status, response.body], [500, ""]); + ok(!("content-type" in response.headers)); + } + await captured.waitFor("The provided `error_handler` has failed"); + }, + ); + }); + + test("an opaque `500` that cannot be sent is logged", async () => { + await withApp( + { + error_handler: (context: Context): never => { + context.request.destroy(); + throw new Error("A failing error handler"); + }, + }, + (app) => { + app.use(() => { + throw new BindenError(400); + }); + }, + async ({ port }, captured) => { + await rejects(send(port)); + await captured.waitFor("The error reply has not been sent"); + }, + ); + }); + + test("a late failure of an error handler is logged, and its late reply ignored", async () => { + await withApp( + { + error_handler_timeout: 20, + error_handler: async (context: Context): Promise => { + await sleep(60); + await context.send("late"); + throw new Error("late"); + }, + }, + (app) => { + app.use(() => { + throw new BindenError(400); + }); + }, + async ({ port }, captured) => { + const response = await send(port); + deepStrictEqual([response.status, response.body], [500, ""]); + await captured.waitFor("A late reply has been ignored"); + await captured.waitFor( + "The provided `error_handler` has failed late", + ); + }, + ); + }); + + test("an error handler that does not settle in time once it has replied closes the connection", async () => { + await withApp( + { + error_handler_timeout: 20, + error_handler: async (context: Context): Promise => { + // Chunked: an ended reply would look complete + context.response.writeHead(400); + context.response.write("abc"); + await never(); + }, + }, + (app) => { + app.use(() => { + throw new BindenError(400); + }); + }, + async ({ port }) => { + await rejects(send(port), { code: "ECONNRESET" }); + }, + ); + }); + }); +}); diff --git a/packages/binden/test/process.test.ts b/packages/binden/test/process.test.ts index 655364c1..9c72114a 100644 --- a/packages/binden/test/process.test.ts +++ b/packages/binden/test/process.test.ts @@ -34,6 +34,14 @@ suite("Process safety", () => { }); }); + test("hung middlewares and error handlers, and their late failures, do not crash the process", () => { + deepStrictEqual(run("hangs.ts"), { + status: 0, + stdout: "503 503 500 500 500\n", + stderr: "", + }); + }); + test("a logger whose `child()` throws does not crash the process", () => { deepStrictEqual(run("throwing-logger.ts"), { status: 0, From 315fc2afd501ff485c0f42bb1bc177b0a9d77422 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:02:17 +0300 Subject: [PATCH 140/675] feat!: add Stack, route patterns and mounting in binden Routing was limited to exact strings and RegExps on one flat stack, so path parameters and reusable sub-applications needed hand-written matching. `Stack` is an option-less container of middlewares (`use`, `route`, `mount`, `off`, `stack`), and `Binden` is now its root, with the options that apply to every mounted stack. `route(pattern)` matches `:name` (one segment) and `*name` (the rest of the path) parameters, with escapes for literal `:` and `*`; the path is split before decoding, every parameter is decoded once, and a parameter that is not valid UTF-8 or that hides an encoded `/` or `\` is a 400. `mount(prefix)` matches on a segment boundary: the mounted items see the rest of the path, with the parameters, `mount_path` and template merged and restored afterwards. A stack mounted in itself is refused by `mount()`, and a cycle found while routing fails the request. `routing` options make the literals case-insensitive and allow one trailing slash. Routers take a method in any case (an HTTP token), let their own `HEAD` and `OPTIONS` middlewares come first, answer `OPTIONS` with 204 and the union of the methods of the matching guarded routers, and add `OPTIONS` to `Allow`. The state and the parameters are typed through `IFunctionMiddleware`, `Middleware`, `Router`, `route()` and `Stack`; the `state` option creates the state of every request and is required by the types when the state has required fields. A type test checks these generics. BREAKING CHANGE: `IBindenOptions` is a type (with a `State` parameter), the `Allow` of a 405 lists `OPTIONS`, `OPTIONS` to guarded routers without their own `OPTIONS` middlewares gets 204 instead of 405, `Router.on()` accepts any token in any case, and `Middleware`, `Router` and `Binden` throw a `TypeError` on options of the wrong type. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 125 ++++-- packages/binden/index.ts | 8 + packages/binden/src/binden.ts | 481 +++++++++++--------- packages/binden/src/exchange.ts | 29 ++ packages/binden/src/middleware.ts | 46 +- packages/binden/src/route.ts | 254 +++++++++++ packages/binden/src/router.ts | 91 ++-- packages/binden/src/stack.ts | 300 +++++++++++++ packages/binden/test/binden.test.ts | 6 +- packages/binden/test/helpers.ts | 9 +- packages/binden/test/middleware.test.ts | 13 +- packages/binden/test/router.test.ts | 55 ++- packages/binden/test/routing.test.ts | 560 ++++++++++++++++++++++++ packages/binden/test/types.test.ts | 115 +++++ 14 files changed, 1796 insertions(+), 296 deletions(-) create mode 100644 packages/binden/src/route.ts create mode 100644 packages/binden/src/stack.ts create mode 100644 packages/binden/test/routing.test.ts create mode 100644 packages/binden/test/types.test.ts diff --git a/packages/binden/README.md b/packages/binden/README.md index 7ddcdb86..cc519a1f 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -12,16 +12,61 @@ npm install binden ### Binden -`new Binden(options)` takes the following options: +An application is a `Binden`: the root [stack](#stack-and-routing), whose options apply to every request (they are validated by the constructor: an invalid option throws a `TypeError` or a `RangeError`). + +| Option | Default | Description | +| ------------------------------------------------------------------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auto_head` | `true` | A router without `HEAD` middlewares handles `HEAD` with its `GET` middlewares (see [Router](#router)) | +| `error_handler` | none | Replaces the default reply to an error (see [Errors](#errors)) | +| `error_handler_timeout` | `5000` | The milliseconds that the `error_handler` has to settle (an integer from 1 to 2³¹−1) | +| `timeout` | `0` (none) | The deadline of every request in milliseconds (an integer from 0 to 2³¹−1, see [Lifecycle](#lifecycle)) | +| `logger` | silent unless `BINDEN_LOG_LEVEL` is set | See [Logging](#logging) | +| `routing` | `{ case_sensitive: true, strict: true }` | The matching of the patterns of `route()` and `mount()`: `case_sensitive: false` compares their literal segments without their case, and `strict: false` ignores one trailing `/` (see [Stack and routing](#stack-and-routing)) | +| `state` | `() => ({})` | Creates `context.state` for every request (required by the types when the state has required fields) | +| `trust_proxy`, `allowed_hosts`, `request_id`, `encoded_slashes`, `query` | see [Request options](#request-options) | The options of the requests | -- `auto_head` (default `true`) - a router without `HEAD` middlewares handles `HEAD` with its `GET` middlewares (see [Router](#router)) -- `error_handler` - replaces the default reply to an error thrown by a middleware (the thrown value may be anything, also `null`). It may be `async`: it is awaited, and it must have ended its reply when it settles. Nothing started then gets the default reply, while a reply that is not ended (e.g. a `write()` without `end()`) is cut by closing the connection. A handler that throws, rejects or does not settle within `error_handler_timeout` gets an empty `500 Internal Server Error` instead (without the framing and representation headers it may have set), or the connection is closed when its reply has started; its later reply is ignored, and its later failure is logged (`debug`). -- `error_handler_timeout` (default `5000`) - the milliseconds the `error_handler` has to settle (an integer from 1 to 2³¹−1) -- `timeout` (default `0`, none) - the deadline of every request in milliseconds (an integer from 0 to 2³¹−1, see [Lifecycle](#lifecycle)) -- `logger` - see [Logging](#logging) -- `trust_proxy`, `allowed_hosts`, `request_id`, `encoded_slashes`, `query` - the options of the requests, see [Request options](#request-options) +- `.createServer()` - create a server (HTTP) + +```typescript +import { Binden } from "binden"; + +const app = new Binden() + .use(new RegExp("path"), middleware) + .use("/path2", router); +const server = app.createServer(); +``` + +- `.createSecureServer()` - create a server (HTTPS) + +```typescript +import { Binden } from "binden"; -- `.use()` - Add a `Middleware/Router` to the stack +const app = new Binden().use("/path", middleware).use("/path2", router); +const secureServer = app.createSecureServer({ key, cert }); +``` + +The state of a request is typed by the application, and so is every middleware added to it: + +```typescript +import { Binden } from "binden"; + +interface IState { + user: string | null; +} + +const app = new Binden({ state: () => ({ user: null }) }); +app.use((context) => { + context.state.user = context.request.header("x-user") as string | null; +}); +``` + +Middlewares typed for the default state (such as the `Middleware` classes of other packages) can be used with any state. A function middleware typed for the default state (`(context: Context) => …`) needs a state declared as a `type` (an `interface` has no index signature). + +### Stack and routing + +A `Stack` holds middlewares and routers by path. It has no options: the options of the application apply to every stack mounted in it (a `Binden` is a stack too, but it cannot be mounted). + +- `.use()` - add middlewares and routers for every path, for an exact path (a string, compared as received) or for the paths that a RegExp matches (a RegExp keeps its flags, except `g` and `y`, which would make `test()` stateful) ```typescript import { Binden, type Context } from "binden"; @@ -34,38 +79,48 @@ const middleware3 = (context: Context): Promise => app.use("/path2", middleware3); ``` -A RegExp path keeps its flags, except `g` and `y` (they would make `test()` stateful). - -- `.off()` - remove a `Middleware/Router` from the stack (returns the removed items) +- `.route()` - add middlewares and routers for the paths that a pattern matches, with its parameters in `context.params` (typed from the pattern): + - `:name` matches one non-empty segment, and `*name` (the last segment only) the rest of the path (zero or more segments); + - a name is `[A-Za-z_][A-Za-z0-9_]*`, and `\:` and `\*` are a literal `:` and `*`; + - the path is split into its segments first, then every parameter (and every segment of `*name`) is decoded once: a parameter that is not valid percent-encoded UTF-8, or that has an encoded `/` or `\` (`%2F`, `%5C`), is `400 Bad Request`. Literal segments are compared once decoded (write them decoded); + - a pattern without a leading `/`, with an empty or duplicate name, a `*name` that is not last, or a `:` or `*` inside a segment (unless escaped) throws a `TypeError`. ```typescript import { Binden } from "binden"; -const app = new Binden().use("/path", middleware1).use(middleware2); -// `.off()` returns the removed items -const removed = app.off("/path", middleware1); +const app = new Binden() + .route("/users/:id", (context) => context.json({ id: context.params.id })) + .route("/files/*path", (context) => context.text(context.params.path)); ``` -- `.createServer()` - create a server (HTTP) +- `.mount()` - add middlewares, routers and other stacks for the paths under a prefix (the pattern of `route()`, without `*name`), on a segment boundary (`/api` matches `/api` and `/api/x`, not `/apix`). The mounted items see the rest of the path (`/` when there is none); the parameters of the prefix are added to `context.params`, and `context.mount_path` is the matched prefix (both are restored once the mounted items are done). A stack can be mounted in several places, but not in itself (directly or through other stacks: a `TypeError`). ```typescript -import { Binden } from "binden"; +import { Binden, Router, Stack } from "binden"; -const app = new Binden() - .use(new RegExp("path"), middleware) - .use("/path2", router); -const server = app.createServer(); +const users = new Stack() + .use( + "/", + new Router().get((context) => context.json([])), + ) + .route("/:id", (context) => + context.json({ id: context.params.id, path: context.mount_path }), + ); +const app = new Binden().mount("/api/users", users); ``` -- `.createSecureServer()` - create a server (HTTPS) +- `.off()` - remove middlewares and routers added by `.use()` (returns the removed items) ```typescript import { Binden } from "binden"; -const app = new Binden().use("/path", middleware).use("/path2", router); -const secureServer = app.createSecureServer({ key, cert }); +const app = new Binden().use("/path", middleware1).use(middleware2); +// `.off()` returns the removed items +const removed = app.off("/path", middleware1); ``` +- `.stack` - a copy of the layers of the stack: their items, and their path (a string, a RegExp, `null` for every path, `{ route }` or `{ mount }`) + ### Logging Every context logs through `context.log`: a child (with the `trace_id` of the context) of the logger of the application. The default logger (see [`@binden/logger`](https://github.com/binden-js/binden/tree/main/packages/logger)) is silent unless the `BINDEN_LOG_LEVEL` environment variable sets a level, and its `serializers` log an allowlist of details only, which keeps credentials and the input of the requests out of the logs: @@ -384,9 +439,9 @@ const router = new Router() const app = new Binden().use("/items", router); ``` -- `.get()`, `.head()`, `.post()`, `.put()`, `.patch()`, `.delete()`, `.options()`, `.trace()`, and `.on()` for any other method of Node.js but `CONNECT`, which Node.js emits as a `connect` event of the server: its requests never reach the middlewares. `.off()` removes middlewares, and `.methods` lists the methods that have some. +- `.get()`, `.head()`, `.post()`, `.put()`, `.patch()`, `.delete()`, `.options()`, `.trace()`, and `.on()` for any other method, in any case (`.on("propfind")` is `PROPFIND`): a method must be an HTTP token, and `CONNECT` throws a `TypeError` (Node.js emits its requests as `connect` events of the server: they never reach the middlewares). `.off()` removes middlewares, and `.methods` lists the methods that have some. - `HEAD` is handled by the `HEAD` middlewares of a router, or else by its `GET` middlewares (unless the `auto_head` option of `Binden` is `false`). A response to `HEAD` sends no body. -- A `guarded` router (`new Router({ guarded: true })`) that does not allow the method of a request passes it on. When nothing else handles the request, it gets `405 Method Not Allowed`, with the methods of every guarded router that has refused it in `Allow`. +- A `guarded` router (`new Router({ guarded: true })`) that does not allow the method of a request passes it on. When nothing else handles the request, it gets `405 Method Not Allowed`, whose `Allow` lists the methods of every guarded router that has refused it (with `HEAD` for `GET` when `auto_head` is `true`, and `OPTIONS`). An `OPTIONS` request that none of them has middlewares for gets `204 No Content` with the same `Allow` instead; their own `OPTIONS` (or `HEAD`) middlewares take precedence. ```typescript import { Binden, type Context, Router } from "binden"; @@ -397,10 +452,28 @@ const list = new Router({ guarded: true }).get((context: Context) => const create = new Router({ guarded: true }).post((context: Context) => context.status(201).send(), ); -// `DELETE /items` gets 405, with `Allow: GET, HEAD, POST` +// `DELETE /items` gets 405, and `OPTIONS /items` 204, with +// `Allow: GET, HEAD, POST, OPTIONS` const app = new Binden().use("/items", list, create); ``` +### Errors + +A middleware that throws (or rejects) passes the error to the error handler. The default one replies with the status of a `BindenError` (see below), or `500 Internal Server Error` for any other value; a reply that has already started is cut by closing the connection instead. + +The `error_handler` option replaces it. It gets the context and the thrown value (which may be anything, also `null`), may be `async` (it is awaited), and must have ended its reply when it settles: nothing started then gets the default reply, while a reply that is not ended (e.g. a `write()` without `end()`) is cut by closing the connection. A handler that throws, rejects or does not settle within `error_handler_timeout` gets an empty `500 Internal Server Error` instead (without the framing and representation headers it may have set), or the connection is closed when its reply has started; its later reply is ignored, and its later failure is logged (`debug`). + +```typescript +import { Binden, BindenError, type Context } from "binden"; + +const app = new Binden({ + error_handler: async (context: Context, error: unknown): Promise => { + const status = error instanceof BindenError ? error.status : 500; + await context.status(status).json({ error: status }); + }, +}); +``` + ### BindenError `BindenError` represents an HTTP error, with a status code from 400 to 599 (an integer: anything else throws a `TypeError`) diff --git a/packages/binden/index.ts b/packages/binden/index.ts index 600a4883..d5111264 100644 --- a/packages/binden/index.ts +++ b/packages/binden/index.ts @@ -20,4 +20,12 @@ export * from "./src/response.js"; export type { IRedirectOptions, IRedirectStatus } from "./src/redirect.js"; export type { ISendFileOptions } from "./src/send-file.js"; export * from "./src/router.js"; +export type { IRouteParams, IRoutingOptions } from "./src/route.js"; +export { + Stack, + type IStack, + type IStackItem, + type IStackPath, + type IUntypedItem, +} from "./src/stack.js"; export { serializers } from "./src/logger.js"; diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index ac2c9c27..935dca58 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -1,6 +1,5 @@ import http from "node:http"; import https from "node:https"; -import { isDeepStrictEqual } from "node:util"; import type { ILogger } from "@binden/logger"; import { Context } from "./context.js"; import { BindenError } from "./error.js"; @@ -10,22 +9,32 @@ import { Middleware, type IMiddleware } from "./middleware.js"; import { property } from "./property.js"; import { BindenRequest, type ITrustProxy } from "./request.js"; import { BindenResponse, ct_json } from "./response.js"; +import { matchPrefix, matchRoute, type IRoutingOptions } from "./route.js"; import { Router } from "./router.js"; +import { type ILayer, layersOf, markRoot, Stack } from "./stack.js"; import { isStatus } from "./status.js"; -type IUseOptions = IStackItem | RegExp | string | null | undefined; - -export type IStackItem = IMiddleware | Router; -export type IStack = [IStackItem[], RegExp | string | null]; - export const DefaultErrorCode = 500; type ILogLevel = "debug" | "error" | "trace" | "warn"; -export interface IBindenOptions { - auto_head?: boolean; +/** Replaces the default reply to an error (see `IBindenOptions.error_handler`) */ +export type IErrorHandler> = ( + context: Context, + error: unknown, +) => Promise | void; + +/** The options of `Binden`, but its `state` */ +export interface IBindenBaseOptions< + State extends object = Record, +> { + /** + * Whether a router without `HEAD` middlewares handles `HEAD` with its `GET` + * middlewares (`true`) + */ + auto_head?: boolean | undefined; /** Replaces the default reply to an error, unless it does not reply itself */ - error_handler?: (context: Context, error: unknown) => Promise | void; + error_handler?: IErrorHandler | undefined; /** * The time in milliseconds that the `error_handler` has to settle (`5000`): * a handler that takes longer, throws or rejects gets an empty @@ -42,15 +51,27 @@ export interface IBindenOptions { */ timeout?: number | undefined; /** The logger of the contexts (the default one is set up by `BINDEN_LOG_LEVEL`) */ - logger?: ILogger; + logger?: ILogger | undefined; + /** The matching of the patterns of `route()` and `mount()` */ + routing?: IRoutingOptions | undefined; /** * The proxies whose `Forwarded` elements `request.protocol` trusts: none * (`false`, the default), all of them (`true`) or the given number of the * nearest ones */ - trust_proxy?: ITrustProxy; + trust_proxy?: ITrustProxy | undefined; } +/** + * The options of `Binden`: `state` creates the state of every request (an + * empty object by default), and is required when the state has required fields + */ +export type IBindenOptions> = + IBindenBaseOptions & + (Record extends State + ? { state?: (() => State) | undefined } + : { state: () => State }); + /** The greatest delay of a Node.js timer */ const MAX_DELAY = 2 ** 31 - 1; @@ -60,24 +81,60 @@ const ABORTED = new Error("The request has been aborted"); /** The outcome of an `error_handler` that has not settled in time */ const EXPIRED = Symbol("expired"); -export class Binden { - readonly #stack: IStack[]; +/** The context of a request, which a middleware may replace */ +interface IRun { + context: Context; +} + +/** The methods of the guarded routers that have refused a request */ +type IAllow = Set; + +/** A level of the routing that a matching layer enters */ +interface ILevel { + readonly params: Readonly>; + /** The matched prefix of a mount, as received */ + readonly prefix: string; + /** The matched prefix of a mount, as registered */ + readonly template: string; + /** The rest of the path, which the items of a mount see */ + readonly rest: string; +} + +/** + * An application: the root stack, with the options that apply to every stack + * mounted in it + */ +export class Binden< + State extends object = Record, +> extends Stack { readonly #auto_head: boolean; - readonly #error_handler: - ((context: Context, error: unknown) => Promise | void) | null; + readonly #error_handler: IErrorHandler | null; readonly #error_handler_timeout: number; readonly #timeout: number; readonly #logger: ILogger; + readonly #routing: Required; + readonly #state: (() => object) | null; readonly #Request: typeof BindenRequest; - public constructor({ - error_handler, - error_handler_timeout = 5000, - auto_head = true, - logger = defaultLogger, - timeout = 0, - trust_proxy = false, - }: IBindenOptions = {}) { + public constructor( + ...[options = {} as IBindenOptions]: Record< + never, + never + > extends State + ? [options?: IBindenOptions] + : [options: IBindenOptions] + ) { + super(); + const { + error_handler, + error_handler_timeout = 5000, + auto_head = true, + logger = defaultLogger, + routing = {}, + state, + timeout = 0, + trust_proxy = false, + } = options; if ( typeof trust_proxy === "number" && !(Number.isSafeInteger(trust_proxy) && trust_proxy >= 0) @@ -86,12 +143,29 @@ export class Binden { } Binden.#delay("timeout", timeout, 0); Binden.#delay("error_handler_timeout", error_handler_timeout, 1); - this.#stack = []; - this.#error_handler = error_handler ?? null; + Binden.#boolean("auto_head", auto_head); + if ( + typeof error_handler !== "undefined" && + typeof error_handler !== "function" + ) { + throw new TypeError("`error_handler` is not a function"); + } else if (typeof state !== "undefined" && typeof state !== "function") { + throw new TypeError("`state` is not a function"); + } else if (typeof routing !== "object" || (routing as unknown) === null) { + throw new TypeError("`routing` is not an object"); + } + const { case_sensitive = true, strict = true } = routing; + Binden.#boolean("routing.case_sensitive", case_sensitive); + Binden.#boolean("routing.strict", strict); + markRoot(this); + this.#error_handler = + (error_handler as unknown as IErrorHandler | undefined) ?? null; this.#error_handler_timeout = error_handler_timeout; this.#timeout = timeout; this.#auto_head = auto_head; this.#logger = logger; + this.#routing = { case_sensitive, strict }; + this.#state = state ?? null; // The requests of this application trust its proxies this.#Request = class extends BindenRequest { // eslint-disable-next-line class-methods-use-this -- the option of the app @@ -101,13 +175,7 @@ export class Binden { }; } - public get stack(): IStack[] { - return this.#stack.map(([r, p]) => [ - [...r], - p instanceof RegExp ? new RegExp(p.source, p.flags) : p, - ]); - } - + /** Creates an HTTP server that handles its requests with the application */ public createServer( options: Omit< http.ServerOptions, @@ -126,6 +194,7 @@ export class Binden { ); } + /** Creates an HTTPS server that handles its requests with the application */ public createSecureServer( options: Omit< https.ServerOptions, @@ -144,107 +213,17 @@ export class Binden { ); } - public use(...items: IStackItem[]): this; - public use(path: RegExp | string, ...items: IStackItem[]): this; - public use(_path: IUseOptions, ...items: IStackItem[]): this { - if (Binden.#validateItem(_path)) { - items.unshift(_path); - } else if (!items.length) { - return this; - } - - const path = - _path instanceof RegExp - ? Binden.#pattern(_path) - : typeof _path === "string" && _path - ? _path - : null; - - for (const item of items) { - if (!Binden.#validateItem(item)) { - throw new TypeError("Unsupported Middleware/Router"); - } - } - - if (this.#stack.length) { - const [arr, previous_path] = this.#stack[this.#stack.length - 1]; - - if (isDeepStrictEqual(previous_path, path)) { - arr.push(...items); - return this; - } - } - - this.#stack.push([[...items], path]); - return this; - } - - public off(...items: IStackItem[]): IStackItem[]; - public off(path: RegExp | string, ...items: IStackItem[]): IStackItem[]; - public off(_path: IUseOptions, ...items: IStackItem[]): IStackItem[] { - if (Binden.#validateItem(_path)) { - items.unshift(_path); - } else if (!items.length || !this.#stack.length) { - return []; - } - - const path = - _path instanceof RegExp - ? Binden.#pattern(_path) - : typeof _path === "string" && _path - ? _path - : null; - - const removed: IStackItem[] = []; - - for (const item of items) { - const { length } = this.#stack; - - for (let i = length - 1; i >= 0; i -= 1) { - const [arr, p] = this.#stack[i]; - - if (isDeepStrictEqual(p, path)) { - const index = arr.lastIndexOf(item); - - if (index !== -1) { - const found = arr.splice(index, 1); - removed.push(...found); - - if (!arr.length) { - if (i > 0 && i < length - 1) { - const [p_arr, p_path] = this.#stack[i - 1]; - const [n_arr, n_path] = this.#stack[i + 1]; - - if (isDeepStrictEqual(p_path, n_path)) { - p_arr.push(...n_arr); - this.#stack.splice(i, 2); - } else { - this.#stack.splice(i, 1); - } - } else { - this.#stack.splice(i, 1); - } - } - - break; - } - } - } - } - - return removed; - } - #requestListener( request: BindenRequest, response: BindenResponse, ): void { + const { state, failure } = this.#createState(); + const exchange = Exchange.for(request, response, { state }); const context = new Context({ request, response, logger: this.#logger }); - const exchange = Exchange.for(request, response); if (this.#timeout > 0) { exchange.setTimeout(this.#timeout); } - this.#run(context, exchange).catch((error: unknown) => { + this.#run(context, exchange, failure).catch((error: unknown) => { // Unexpected (every failure is handled): the process must not crash Binden.#log(context, "error", "The request has failed", { error }); response.destroy(); @@ -256,12 +235,18 @@ export class Binden { * deadline that passes later (e.g. while a stream is sent) closes the * connection of a response that has not finished. */ - async #run(first: Context, exchange: Exchange): Promise { - const run = { context: first }; - let failure: { error: unknown } | null = null; + async #run( + first: Context, + exchange: Exchange, + initial: { error: unknown } | null, + ): Promise { + const run: IRun = { context: first }; + let failure = initial; let stopped = false; try { - await this.#handle(run, exchange); + if (failure === null) { + await this.#handle(run, exchange); + } } catch (error: unknown) { if (error === ABORTED) { stopped = true; @@ -299,78 +284,156 @@ export class Binden { } } - async #handle(run: { context: Context }, exchange: Exchange): Promise { - let next = run.context; - // The methods of the guarded routers that have refused the request - const allow = new Set(); - - for (const [items, path] of this.#stack) { - const regExp = path instanceof RegExp && path.test(next.url.pathname); - const str = typeof path === "string" && path === next.url.pathname; - - if (path === null || regExp || str) { - for (const item of items) { - if (item instanceof Router) { - const { guarded, middlewares, methods } = item; - const { method = "GET" } = next.request; - // `HEAD` is handled by the `GET` middlewares, unless it has its own - const head = methods.has("HEAD"); - - if ( - guarded && - methods.size && - methods.has("GET") && - this.#auto_head - ) { - methods.add("HEAD"); - } - - // A guarded router that does not allow the method: another one may - // (405 when none does) - const refused = guarded && methods.size > 0 && !methods.has(method); - if (refused) { - for (const allowed of methods) { - allow.add(allowed); - } - } - - const m = - method === "HEAD" && this.#auto_head && !head ? "GET" : method; - const methodMiddlewares = refused ? [] : middlewares(m); - - for (const mw of methodMiddlewares) { - next = await Binden.#runMiddleware(mw, next, exchange); - run.context = next; - - if (next.done) { - await Binden.#complete(next); - return; - } - } - } else { - next = await Binden.#runMiddleware(item, next, exchange); - run.context = next; - - if (next.done) { - await Binden.#complete(next); - return; - } - } - } - } + /** + * Routes the request through the stack: `404 Not Found` when nothing has + * handled it, or `405 Method Not Allowed` when only guarded routers have + * refused its method (or `204` with the allowed methods for `OPTIONS`) + */ + async #handle(run: IRun, exchange: Exchange): Promise { + const { pathname } = run.context.url; + const allow: IAllow = new Set(); + + if (await this.#walk(this, run, exchange, pathname, [], allow)) { + return; } // Nothing has handled the request, unless a middleware has started (or ended) // the response without setting `done`: it is left to finish - if (next.response.started) { + const { request, response } = run.context; + if (response.started) { return; } else if (allow.size) { - next.response.set({ Allow: [...allow].join(", ") }); + allow.add("OPTIONS"); + const methods = [...allow].join(", "); + if (request.method === "OPTIONS") { + response.status(204).setHeader("Allow", methods); + await response.send(); + return; + } + response.setHeader("Allow", methods); throw new BindenError(405); } throw new BindenError(404); } + /** + * Runs the layers of a stack that match the path (the rest of the path + * at a mounted level): `true` once a middleware has set `done`. A stack that + * is already being run (mounted in itself) fails the request. + */ + async #walk( + stack: object, + run: IRun, + exchange: Exchange, + path: string, + chain: object[], + allow: IAllow, + ): Promise { + if (chain.includes(stack)) { + throw new Error("A stack is mounted in itself"); + } + chain.push(stack); + + for (const layer of layersOf(stack)) { + const level = this.#match(layer, path); + if ( + level !== null && + (await this.#layer(layer, level, run, exchange, chain, allow)) + ) { + return true; + } + } + + chain.pop(); + return false; + } + + /** Runs the items of a layer that matches: `true` once one has set `done` */ + async #layer( + layer: ILayer, + level: ILevel, + run: IRun, + exchange: Exchange, + chain: object[], + allow: IAllow, + ): Promise { + const leave = exchange.enter(level); + let { context } = run; + for (const item of layer.items) { + if (item instanceof Stack) { + if (await this.#walk(item, run, exchange, level.rest, chain, allow)) { + return true; + } + // A middleware of the mounted stack may have replaced the context + ({ context } = run); + } else { + const middlewares = + item instanceof Router ? this.#methodOf(item, run, allow) : [item]; + for (const middleware of middlewares) { + context = await Binden.#runMiddleware(middleware, context, exchange); + run.context = context; + if (context.done) { + await Binden.#complete(context); + return true; + } + } + } + } + // Back to the level of the stack + leave(); + return false; + } + + /** + * The level that a layer of the stack enters for a path (its parameters, + * and the prefix and the rest of the path of a mount), or `null` + */ + #match(layer: ILayer, path: string): ILevel | null { + const none = { params: {}, prefix: "", template: "", rest: path }; + if (layer.kind === "use") { + const { path: match } = layer; + return match === null || + (match instanceof RegExp ? match.test(path) : match === path) + ? none + : null; + } else if (layer.kind === "route") { + const params = matchRoute(layer.pattern, path, this.#routing); + return params === null ? null : { ...none, params }; + } + const { pattern } = layer; + const match = matchPrefix(pattern, path, this.#routing); + return match === null ? null : { ...match, template: pattern.source }; + } + + /** + * The middlewares of a router for the method of the request: `HEAD` is + * handled by the `GET` middlewares, unless it has its own, and a guarded + * router that does not allow the method (an `OPTIONS` that it has no + * middlewares for among them) refuses the request, which another one may + * handle + */ + #methodOf(router: Router, run: IRun, allow: IAllow): IMiddleware[] { + const { guarded, middlewares, methods } = router; + const { method = "GET" } = run.context.request; + const head = methods.has("HEAD"); + + if (guarded && methods.size) { + if (methods.has("GET") && this.#auto_head) { + methods.add("HEAD"); + } + if (!methods.has(method)) { + for (const allowed of methods) { + allow.add(allowed); + } + return []; + } + } + + return middlewares( + method === "HEAD" && this.#auto_head && !head ? "GET" : method, + ); + } + /** * The request has been aborted while the stack ran (the stack has stopped): * after the deadline, a `503` error is replied through the error handler @@ -684,14 +747,6 @@ export class Binden { } } - /** - * A RegExp path with its flags (e.g. `i`), except those that make `test()` - * stateful (`g` and `y`) - */ - static #pattern(path: RegExp): RegExp { - return new RegExp(path.source, path.flags.replaceAll(/[gy]/gu, "")); - } - /** A delay of a timer: an integer from `min` to the greatest delay of Node.js */ static #delay(name: string, value: unknown, min: number): void { if (typeof value !== "number" || !Number.isInteger(value)) { @@ -703,12 +758,30 @@ export class Binden { } } - static #validateItem(item: unknown): item is IStackItem { - return ( - item instanceof Middleware || - item instanceof Router || - typeof item === "function" - ); + /** + * The state of a request, from the `state` option: a factory that fails (or + * that does not return an object) fails the request + */ + #createState(): { state: object; failure: { error: unknown } | null } { + if (!this.#state) { + return { state: {}, failure: null }; + } + try { + const state: unknown = this.#state(); + if (typeof state !== "object" || state === null) { + throw new TypeError("The state of the request is not an object"); + } + return { state, failure: null }; + } catch (error: unknown) { + return { state: {}, failure: { error } }; + } + } + + /** A boolean option */ + static #boolean(name: string, value: unknown): void { + if (typeof value !== "boolean") { + throw new TypeError(`\`${name}\` is not a boolean`); + } } } diff --git a/packages/binden/src/exchange.ts b/packages/binden/src/exchange.ts index 6caa2d9f..d9eb4389 100644 --- a/packages/binden/src/exchange.ts +++ b/packages/binden/src/exchange.ts @@ -168,6 +168,35 @@ export class Exchange { this.#mount_template = template; } + /** + * Enters a level of the routing: its parameters are merged with the + * current ones, and its prefix (as matched and as registered) appended to + * the current ones. The returned function goes back to the current level. + */ + public enter({ + params, + prefix, + template, + }: { + params: Readonly>; + prefix: string; + template: string; + }): () => void { + const current = { + params: this.#params, + mount_path: this.#mount_path, + mount_template: this.#mount_template, + }; + this.params = { ...current.params, ...params }; + this.#mount_path += prefix; + this.#mount_template += template; + return (): void => { + this.#params = current.params; + this.#mount_path = current.mount_path; + this.#mount_template = current.mount_template; + }; + } + public get phase(): IPhase { return this.#phase; } diff --git a/packages/binden/src/middleware.ts b/packages/binden/src/middleware.ts index 50987842..3b1e10e5 100644 --- a/packages/binden/src/middleware.ts +++ b/packages/binden/src/middleware.ts @@ -1,20 +1,35 @@ -import type { Context } from "./context.js"; +import type { Context, IParams } from "./context.js"; -type IMiddlewareReturnType = - | Context - | Promise +/** What a middleware returns: nothing, or the context of the next middlewares */ +type IMiddlewareReturnType< + State extends object = Record, + Params extends IParams = IParams, +> = + | Context + | Promise> | Promise // eslint-disable-next-line @typescript-eslint/no-invalid-void-type | void; -export type IFunctionMiddleware = (context: Context) => IMiddlewareReturnType; +/** A middleware as a function of the context */ +export type IFunctionMiddleware< + State extends object = Record, + Params extends IParams = IParams, +> = (context: Context) => IMiddlewareReturnType; +/** The options of a `Middleware` */ export interface IMiddlewareParams { - disabled?: boolean; - ignore_errors?: boolean; + /** Whether the middleware is skipped (`false`) */ + disabled?: boolean | undefined; + /** Whether the errors of the middleware are ignored (`false`) */ + ignore_errors?: boolean | undefined; } -export abstract class Middleware { +/** A middleware as a class: its `run()` handles the context */ +export abstract class Middleware< + State extends object = Record, + Params extends IParams = IParams, +> { public disabled: boolean; public ignore_errors: boolean; @@ -22,11 +37,22 @@ export abstract class Middleware { disabled = false, ignore_errors = false, }: IMiddlewareParams = {}) { + if (typeof disabled !== "boolean") { + throw new TypeError("`disabled` is not a boolean"); + } else if (typeof ignore_errors !== "boolean") { + throw new TypeError("`ignore_errors` is not a boolean"); + } this.disabled = disabled; this.ignore_errors = ignore_errors; } - public abstract run(context: Context): IMiddlewareReturnType; + public abstract run( + context: Context, + ): IMiddlewareReturnType; } -export type IMiddleware = IFunctionMiddleware | Middleware; +/** A middleware: a function, or an instance of a `Middleware` class */ +export type IMiddleware< + State extends object = Record, + Params extends IParams = IParams, +> = IFunctionMiddleware | Middleware; diff --git a/packages/binden/src/route.ts b/packages/binden/src/route.ts new file mode 100644 index 00000000..06a5b024 --- /dev/null +++ b/packages/binden/src/route.ts @@ -0,0 +1,254 @@ +import type { IParams } from "./context.js"; +import { BindenError } from "./error.js"; + +/** The segments of a path pattern */ +type ISegments = + Pattern extends `${infer Head}/${infer Tail}` + ? Head | ISegments + : Pattern; + +/** The name of a `:name` or `*name` segment */ +type IParamName = Segment extends `:${infer Name}` + ? Name + : Segment extends `*${infer Name}` + ? Name + : never; + +/** + * The parameters of a route pattern (e.g. `{ id: string }` for + * `/users/:id`), or any parameters for a pattern that is not a literal type + */ +export type IRouteParams = string extends Pattern + ? IParams + : Record>, string>; + +/** The options of the matching of `route()` and `mount()` patterns */ +export interface IRoutingOptions { + /** Whether the literal segments are compared with their case (`true`) */ + case_sensitive?: boolean | undefined; + /** Whether a path must not have a trailing `/` that its pattern has not (`true`) */ + strict?: boolean | undefined; +} + +/** A segment of a pattern: a literal, or the name of a parameter */ +type ISegment = { readonly literal: string } | { readonly param: string }; + +/** A compiled `route()` or `mount()` pattern */ +export interface IPattern { + /** The pattern as registered (a mount prefix without its trailing `/`) */ + readonly source: string; + readonly segments: readonly ISegment[]; + /** The segments without a trailing empty one (see the `strict` option) */ + readonly loose: readonly ISegment[]; + /** The name of the `*name` tail, if any */ + readonly wildcard: string | null; +} + +/** The match of a mount prefix */ +export interface IPrefixMatch { + readonly params: IParams; + /** The matched part of the path, as received (`""` for the prefix `/`) */ + readonly prefix: string; + /** The rest of the path that the mounted level sees (`/` when empty) */ + readonly rest: string; +} + +const name = /^[A-Za-z_][A-Za-z0-9_]*$/u; + +/** A literal segment of a pattern: `\:` and `\*` only, no other `:` or `*` */ +function unescape(segment: string, pattern: string): string { + let literal = ""; + for (let index = 0; index < segment.length; index += 1) { + const char = segment.charAt(index); + if (char === "\\") { + const next = segment.charAt(index + 1); + if (next !== ":" && next !== "*") { + throw new TypeError(`Invalid escape in ${pattern}`); + } + literal += next; + index += 1; + } else if (char === ":" || char === "*") { + throw new TypeError(`An unescaped \`${char}\` in ${pattern}`); + } else { + literal += char; + } + } + return literal; +} + +/** + * Compiles a pattern: `/` separated segments, each a literal (where `\:` and + * `\*` are a `:` and a `*`), a `:name` (one segment) or, last and in a route + * only, a `*name` (the rest of the path) + */ +export function compile(pattern: unknown, kind: "mount" | "route"): IPattern { + if (typeof pattern !== "string" || !pattern.startsWith("/")) { + throw new TypeError(`The ${kind} pattern does not start with \`/\``); + } + const raw = pattern.slice(1).split("/"); + if (kind === "mount" && raw.at(-1) === "") { + // `/api/` is `/api`, and `/` mounts at the root + raw.pop(); + } + const segments: ISegment[] = []; + const names = new Set(); + let wildcard: string | null = null; + for (const [index, segment] of raw.entries()) { + const sigil = segment.charAt(0); + if (sigil === ":" || sigil === "*") { + const param = segment.slice(1); + if (!name.test(param)) { + throw new TypeError(`Invalid parameter name in ${pattern}`); + } else if (names.has(param)) { + throw new TypeError(`Duplicate parameter name in ${pattern}`); + } else if (sigil === "*" && kind === "mount") { + throw new TypeError(`A mount prefix has no \`*\` (${pattern})`); + } else if (sigil === "*" && index !== raw.length - 1) { + throw new TypeError(`\`*${param}\` is not the last segment`); + } + names.add(param); + if (sigil === "*") { + wildcard = param; + } else { + segments.push({ param }); + } + } else { + segments.push({ literal: unescape(segment, pattern) }); + } + } + const last = segments.at(-1); + const trailing = + wildcard === null && + segments.length > 1 && + typeof last !== "undefined" && + "literal" in last && + last.literal === ""; + return { + source: kind === "mount" ? raw.map((part) => `/${part}`).join("") : pattern, + segments, + loose: trailing ? segments.slice(0, -1) : segments, + wildcard, + }; +} + +/** A segment of a path, decoded once (`null` when it is not valid) */ +function decode(segment: string): string | null { + try { + return decodeURIComponent(segment); + } catch { + return null; + } +} + +/** The value of a parameter: `400 Bad Request` when it cannot be decoded */ +function value(segment: string): string { + const decoded = decode(segment); + // An encoded `/` (or `\`) would change the segments of the path + if (decoded === null || /[/\\]/u.test(decoded)) { + throw new BindenError(400); + } + return decoded; +} + +/** Whether a segment of a path is a literal of a pattern */ +function same( + segment: string, + literal: string, + case_sensitive: boolean, +): boolean { + if (segment === literal) { + return true; + } + const decoded = decode(segment); + return ( + decoded !== null && + (case_sensitive + ? decoded === literal + : decoded.toLowerCase() === literal.toLowerCase()) + ); +} + +/** + * Matches the leading segments of a path: the parameters, or `null`. The + * literals are compared first, so that a parameter that cannot be decoded + * fails (`400`) only in a path that matches otherwise. + */ +function leading( + segments: readonly ISegment[], + path: readonly string[], + case_sensitive: boolean, +): IParams | null { + for (const [index, segment] of segments.entries()) { + const part = path[index]; + if ("literal" in segment) { + if (!same(part, segment.literal, case_sensitive)) { + return null; + } + } else if (part === "") { + return null; + } + } + const params: [string, string][] = []; + for (const [index, segment] of segments.entries()) { + if ("param" in segment) { + params.push([segment.param, value(path[index])]); + } + } + // Defined, not assigned: a parameter may be named `__proto__` + return Object.fromEntries(params); +} + +/** The segments of a path (which starts with `/`), as received */ +function split(path: string): string[] { + return path.slice(1).split("/"); +} + +/** Matches a whole path: the parameters of a route, or `null` */ +export function matchRoute( + pattern: IPattern, + path: string, + { case_sensitive = true, strict = true }: IRoutingOptions, +): IParams | null { + let parts = split(path); + // One trailing `/` of the path, or of the pattern, may be missing + const segments = strict ? pattern.segments : pattern.loose; + if (!strict && parts.length > 1 && parts.at(-1) === "") { + parts = parts.slice(0, -1); + } + if ( + pattern.wildcard === null + ? parts.length !== segments.length + : parts.length < segments.length + ) { + return null; + } + const params = leading(segments, parts, case_sensitive); + if (params && pattern.wildcard !== null) { + // The rest of the path, each segment decoded once + const rest = parts.slice(segments.length).map(value).join("/"); + return { ...params, ...Object.fromEntries([[pattern.wildcard, rest]]) }; + } + return params; +} + +/** + * Matches the leading segments of a path (on a segment boundary): the + * parameters, the matched part and the rest of the path, or `null` + */ +export function matchPrefix( + pattern: IPattern, + path: string, + { case_sensitive = true }: IRoutingOptions, +): IPrefixMatch | null { + const parts = split(path); + const { length } = pattern.segments; + if (parts.length < length) { + return null; + } + const params = leading(pattern.segments, parts, case_sensitive); + if (!params) { + return null; + } + const prefix = length === 0 ? "" : `/${parts.slice(0, length).join("/")}`; + return { params, prefix, rest: `/${parts.slice(length).join("/")}` }; +} diff --git a/packages/binden/src/router.ts b/packages/binden/src/router.ts index e26df768..59fba630 100644 --- a/packages/binden/src/router.ts +++ b/packages/binden/src/router.ts @@ -1,17 +1,33 @@ -import { METHODS } from "node:http"; +import type { IParams } from "./context.js"; import { Middleware, type IMiddleware } from "./middleware.js"; +/** The options of a `Router` */ export interface IRouterOptions { - guarded?: boolean; + /** + * Whether a request with a method that the router has no middlewares for + * is refused (`false`): `405 Method Not Allowed` (or, for `OPTIONS`, `204` + * with the allowed methods) when nothing else handles it + */ + guarded?: boolean | undefined; } -export class Router { - readonly #middlewares: Map; +/** A method: an HTTP token (RFC 9110, section 9.1) */ +const token = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u; + +/** Middlewares by method, for a path of the stack */ +export class Router< + State extends object = Record, + Params extends IParams = IParams, +> { + readonly #middlewares: Map[]>; #guarded: boolean; public constructor({ guarded = false }: IRouterOptions = {}) { - this.#guarded = Boolean(guarded); - this.#middlewares = new Map(); + if (typeof guarded !== "boolean") { + throw new TypeError("`guarded` is not a boolean"); + } + this.#guarded = guarded; + this.#middlewares = new Map[]>(); } public get guarded(): boolean { @@ -35,16 +51,20 @@ export class Router { return methods; } - public get middlewares(): (method: string) => IMiddleware[] { - return (method: string): IMiddleware[] => [ + /** The middlewares of a method (in any case) */ + public get middlewares(): (method: string) => IMiddleware[] { + return (method: string): IMiddleware[] => [ ...(this.#middlewares.get(method.toUpperCase()) ?? []), ]; } - public on(method: string, ..._middlewares: IMiddleware[]): this { - if (!Router.#supports(method)) { - throw new TypeError(`Method ${method} is not supported`); - } else if (!_middlewares.length) { + /** Adds middlewares for a method (in any case: `get` is `GET`) */ + public on( + _method: string, + ..._middlewares: IMiddleware[] + ): this { + const method = Router.#method(_method); + if (!_middlewares.length) { return this; } @@ -61,19 +81,20 @@ export class Router { if (middlewares) { middlewares.push(..._middlewares); } else { - this.#middlewares.set(method.toUpperCase(), [..._middlewares]); + this.#middlewares.set(method, [..._middlewares]); } return this; } - public off(method: string, ..._middlewares: IMiddleware[]): IMiddleware[] { - if (!Router.#supports(method)) { - throw new TypeError(`Method ${method} is not supported`); - } - + /** Removes middlewares of a method: the removed ones */ + public off( + _method: string, + ..._middlewares: IMiddleware[] + ): IMiddleware[] { + const method = Router.#method(_method); const middlewares = this.#middlewares.get(method) ?? []; - const removed: IMiddleware[] = []; + const removed: IMiddleware[] = []; if (!middlewares.length) { return removed; @@ -95,43 +116,51 @@ export class Router { return removed; } - public delete(...middlewares: IMiddleware[]): this { + public delete(...middlewares: IMiddleware[]): this { return this.on("DELETE", ...middlewares); } - public get(...middlewares: IMiddleware[]): this { + public get(...middlewares: IMiddleware[]): this { return this.on("GET", ...middlewares); } - public head(...middlewares: IMiddleware[]): this { + public head(...middlewares: IMiddleware[]): this { return this.on("HEAD", ...middlewares); } - public options(...middlewares: IMiddleware[]): this { + public options(...middlewares: IMiddleware[]): this { return this.on("OPTIONS", ...middlewares); } - public patch(...middlewares: IMiddleware[]): this { + public patch(...middlewares: IMiddleware[]): this { return this.on("PATCH", ...middlewares); } - public post(...middlewares: IMiddleware[]): this { + public post(...middlewares: IMiddleware[]): this { return this.on("POST", ...middlewares); } - public put(...middlewares: IMiddleware[]): this { + public put(...middlewares: IMiddleware[]): this { return this.on("PUT", ...middlewares); } - public trace(...middlewares: IMiddleware[]): this { + public trace(...middlewares: IMiddleware[]): this { return this.on("TRACE", ...middlewares); } /** - * The methods of Node.js, except `CONNECT`: Node.js emits its requests as - * `connect` events of the server, which never reach the middlewares + * A method in upper case: a token, but not `CONNECT` (Node.js emits its + * requests as `connect` events of the server, which never reach the + * middlewares) */ - static #supports(method: string): boolean { - return METHODS.includes(method) && method !== "CONNECT"; + static #method(method: unknown): string { + if (typeof method !== "string" || !token.test(method)) { + throw new TypeError(`Method ${String(method)} is not a valid token`); + } + const upper = method.toUpperCase(); + if (upper === "CONNECT") { + throw new TypeError("Method CONNECT is not supported"); + } + return upper; } } diff --git a/packages/binden/src/stack.ts b/packages/binden/src/stack.ts new file mode 100644 index 00000000..313765a4 --- /dev/null +++ b/packages/binden/src/stack.ts @@ -0,0 +1,300 @@ +import { isDeepStrictEqual } from "node:util"; +import type { IParams } from "./context.js"; +import { type IMiddleware, Middleware } from "./middleware.js"; +import { compile, type IPattern, type IRouteParams } from "./route.js"; +import { Router } from "./router.js"; + +/** A middleware or a router of a stack, for its state and its parameters */ +export type IStackItem< + State extends object = Record, + Params extends IParams = IParams, +> = IMiddleware | Router; + +/** + * A middleware or a router that is not typed for a state (e.g. of another + * package): it may be used in a stack of any state + */ +export type IUntypedItem = Middleware | Router; + +/** The path of a layer of the stack (see `Stack.stack`) */ +export type IStackPath = + | RegExp + | string + | { readonly mount: string } + | { readonly route: string } + | null; + +/** A layer of the stack: its items, and the path they are used for */ +export type IStack = [(IStackItem | Stack)[], IStackPath]; + +/** A layer of `use()`: an exact path, a RegExp, or any path (`null`) */ +interface IUseLayer { + readonly kind: "use"; + readonly path: RegExp | string | null; + readonly items: IStackItem[]; +} + +/** A layer of `route()` */ +interface IRouteLayer { + readonly kind: "route"; + readonly pattern: IPattern; + readonly items: IStackItem[]; +} + +/** A layer of `mount()`: its items see the rest of the path */ +interface IMountLayer { + readonly kind: "mount"; + readonly pattern: IPattern; + readonly items: (IStackItem | Stack)[]; +} + +/** A layer of a stack, as the routing uses it */ +export type ILayer = IMountLayer | IRouteLayer | IUseLayer; + +/** The applications (`Binden`), which cannot be mounted */ +const roots = new WeakSet(); + +/** Marks the root of an application, which cannot be mounted */ +export function markRoot(stack: object): void { + roots.add(stack); +} + +// eslint-disable-next-line init-declarations -- set by the static block of `Stack` +let layersOf: (stack: object) => ILayer[]; + +/** Whether a value is a middleware or a router */ +function isItem(item: unknown): item is IStackItem { + return ( + item instanceof Middleware || + item instanceof Router || + typeof item === "function" + ); +} + +/** + * A RegExp path with its flags (e.g. `i`), except those that make `test()` + * stateful (`g` and `y`) + */ +function clone(path: RegExp): RegExp { + return new RegExp(path.source, path.flags.replaceAll(/[gy]/gu, "")); +} + +/** The path of `use()`: `null` for any path */ +function usePath(path: unknown): RegExp | string | null { + return path instanceof RegExp + ? clone(path) + : typeof path === "string" && path + ? path + : null; +} + +/** + * A container of middlewares and routers, by path: `use()` for an exact path + * (or a RegExp, or any path), `route()` for a pattern with parameters and + * `mount()` for a prefix, whose items (other stacks among them) see the rest + * of the path. It has no options: those of the application (`Binden`, which + * is a stack too) apply. + */ +export class Stack> { + readonly #layers: ILayer[] = []; + + static { + // Any stack, whatever its state + layersOf = (stack: object): ILayer[] => (stack as Stack).#layers; + } + + /** A copy of the layers of the stack */ + public get stack(): IStack[] { + return this.#layers.map((layer): IStack => { + const items = [...layer.items]; + if (layer.kind === "use") { + const { path } = layer; + return [items, path instanceof RegExp ? clone(path) : path]; + } else if (layer.kind === "route") { + return [items, { route: layer.pattern.source }]; + } + return [items, { mount: layer.pattern.source }]; + }); + } + + /** + * Adds middlewares and routers for every path, for an exact path (a string) + * or for the paths that a RegExp matches + */ + public use(...items: (IStackItem | IUntypedItem)[]): this; + public use( + path: RegExp | string, + ...items: (IStackItem | IUntypedItem)[] + ): this; + public use(_path: unknown, ...rest: unknown[]): this { + const items = isItem(_path) ? [_path, ...rest] : rest; + if (!items.length) { + return this; + } + for (const item of items) { + if (!isItem(item)) { + throw new TypeError("Unsupported Middleware/Router"); + } + } + this.#add({ + kind: "use", + path: usePath(_path), + items: items as IStackItem[], + }); + return this; + } + + /** + * Adds middlewares and routers for the paths that a pattern matches, with + * its parameters in `context.params`: `:name` matches a segment, `*name` + * (last) the rest of the path, and `\:` and `\*` are literal + */ + public route( + pattern: Pattern, + ...items: (IStackItem> | IUntypedItem)[] + ): this { + const compiled = compile(pattern, "route"); + if (!items.length) { + return this; + } + for (const item of items) { + if (!isItem(item)) { + throw new TypeError("Unsupported Middleware/Router"); + } + } + this.#add({ + kind: "route", + pattern: compiled, + items: items as IStackItem[], + }); + return this; + } + + /** + * Adds middlewares, routers and stacks for the paths under a prefix (on a + * segment boundary, with `:name` parameters): they see the rest of the path + * (`/` when there is none), and `context.mount_path` is the matched prefix + */ + public mount( + prefix: string, + ...items: (IStackItem | IUntypedItem | Stack)[] + ): this { + const compiled = compile(prefix, "mount"); + if (!items.length) { + return this; + } + for (const item of items) { + if (item instanceof Stack) { + if (roots.has(item)) { + throw new TypeError("An application (`Binden`) cannot be mounted"); + } else if (item === this || Stack.#reaches(item, this)) { + throw new TypeError("A stack cannot be mounted in itself"); + } + } else if (!isItem(item)) { + throw new TypeError("Unsupported Middleware/Router/Stack"); + } + } + this.#add({ + kind: "mount", + pattern: compiled, + items: items as (IStackItem | Stack)[], + }); + return this; + } + + /** Removes middlewares and routers added by `use()`: the removed ones */ + public off(...items: (IStackItem | IUntypedItem)[]): IStackItem[]; + public off( + path: RegExp | string, + ...items: (IStackItem | IUntypedItem)[] + ): IStackItem[]; + public off(_path: unknown, ...rest: unknown[]): IStackItem[] { + const items = isItem(_path) ? [_path, ...rest] : rest; + const path = usePath(_path); + const removed: IStackItem[] = []; + const layers = this.#layers; + + for (const item of items) { + const { length } = layers; + + for (let i = length - 1; i >= 0; i -= 1) { + const layer = layers[i]; + + if (layer.kind === "use" && isDeepStrictEqual(layer.path, path)) { + const index = layer.items.lastIndexOf(item as IStackItem); + + if (index !== -1) { + removed.push(...layer.items.splice(index, 1)); + + if (!layer.items.length) { + const previous = layers[i - 1]; + const next = layers[i + 1]; + if ( + typeof previous !== "undefined" && + typeof next !== "undefined" && + Stack.#same(previous, next) + ) { + // The neighbours are one layer again + (previous.items as unknown[]).push(...next.items); + layers.splice(i, 2); + } else { + layers.splice(i, 1); + } + } + + break; + } + } + } + } + + return removed; + } + + /** Adds a layer, or its items to the last layer when it has the same path */ + #add(layer: ILayer): void { + const last = this.#layers.at(-1); + if (typeof last !== "undefined" && Stack.#same(last, layer)) { + (last.items as unknown[]).push(...layer.items); + } else { + this.#layers.push(layer); + } + } + + /** Whether two layers have the same path */ + static #same(a: ILayer, b: ILayer): boolean { + if (a.kind === "use" && b.kind === "use") { + return isDeepStrictEqual(a.path, b.path); + } + return ( + a.kind !== "use" && + a.kind === b.kind && + a.pattern.source === b.pattern.source + ); + } + + /** Whether a stack has another one mounted in it (at any depth) */ + static #reaches( + stack: object, + target: object, + seen = new Set(), + ): boolean { + seen.add(stack); + for (const layer of layersOf(stack)) { + if (layer.kind === "mount") { + for (const item of layer.items) { + if ( + item instanceof Stack && + (item === target || + (!seen.has(item) && Stack.#reaches(item, target, seen))) + ) { + return true; + } + } + } + } + return false; + } +} + +export { layersOf }; diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index d99f5f59..c62b5d50 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -855,7 +855,7 @@ suite("Binden", () => { app.use(router); const response = await fetch(url, { method: "POST" }); deepStrictEqual(response.status, 405); - deepStrictEqual(response.headers.get("Allow"), "PATCH, GET, HEAD"); + deepStrictEqual(response.headers.get("Allow"), "PATCH, GET, HEAD, OPTIONS"); }); test("guarded routers (another router allows the method)", async () => { @@ -886,7 +886,7 @@ suite("Binden", () => { deepStrictEqual(await send("/three", "DELETE"), [ 405, "", - "PUT, GET, HEAD", + "PUT, GET, HEAD, OPTIONS", ]); }); @@ -906,7 +906,7 @@ suite("Binden", () => { newApp.use(router); const response = await fetch(path.toString(), { method: "POST" }); deepStrictEqual(response.status, 405); - deepStrictEqual(response.headers.get("Allow"), "PATCH, GET"); + deepStrictEqual(response.headers.get("Allow"), "PATCH, GET, OPTIONS"); await new Promise((resolve) => { newServer.close(resolve); diff --git a/packages/binden/test/helpers.ts b/packages/binden/test/helpers.ts index c80b15ad..451f2c0a 100644 --- a/packages/binden/test/helpers.ts +++ b/packages/binden/test/helpers.ts @@ -8,7 +8,7 @@ import { import type { AddressInfo } from "node:net"; import { Logger } from "@binden/logger"; -import { type Binden, serializers } from "../index.js"; +import { serializers } from "../index.js"; /** Resolves after `ms` milliseconds */ export function sleep(ms: number): Promise { @@ -81,8 +81,13 @@ export interface IListening { close(): Promise; } +/** An application, whatever its state */ +export interface IApp { + createServer(): Server; +} + /** Creates the server of the application and listens on a free port */ -export async function listen(app: Binden): Promise { +export async function listen(app: IApp): Promise { const server: Server = app.createServer(); server.listen(0); await once(server, "listening"); diff --git a/packages/binden/test/middleware.test.ts b/packages/binden/test/middleware.test.ts index 5424fb93..1e16c714 100644 --- a/packages/binden/test/middleware.test.ts +++ b/packages/binden/test/middleware.test.ts @@ -1,4 +1,4 @@ -import { deepStrictEqual } from "node:assert"; +import { deepStrictEqual, throws } from "node:assert"; import { suite, test } from "node:test"; import { Middleware } from "../index.js"; @@ -33,6 +33,17 @@ suite("Middleware", () => { deepStrictEqual(middleware.disabled, false); }); + test("constructor (invalid options)", () => { + throws( + () => new CustomMiddleware({ disabled: 1 as unknown as boolean }), + new TypeError("`disabled` is not a boolean"), + ); + throws( + () => new CustomMiddleware({ ignore_errors: "no" as unknown as boolean }), + new TypeError("`ignore_errors` is not a boolean"), + ); + }); + test("constructor (ignore errors and disabled)", () => { const middleware = new CustomMiddleware({ disabled: true, diff --git a/packages/binden/test/router.test.ts b/packages/binden/test/router.test.ts index 16f6cce1..4e583c00 100644 --- a/packages/binden/test/router.test.ts +++ b/packages/binden/test/router.test.ts @@ -60,20 +60,42 @@ suite("Router", () => { deepStrictEqual(router.methods, new Set(["UNSUBSCRIBE", "GET"])); }); + test(".on() (a method in any case)", () => { + const router = new Router().on("get", one).on("Propfind", two); + deepStrictEqual(router.methods, new Set(["GET", "PROPFIND"])); + deepStrictEqual(router.middlewares("get"), [one]); + deepStrictEqual(router.off("propfind", two), [two]); + deepStrictEqual(router.methods, new Set(["GET"])); + }); + test(".on() (with unsupported method)", () => { - const unsupportedMethod = "_unsupported_"; - throws( - () => new Router().on(unsupportedMethod), - new TypeError(`Method ${unsupportedMethod} is not supported`), - ); + for (const method of ["", "GET POST", "a,b", "(x)", "GÉT", 1]) { + throws( + () => new Router().on(method as string, one), + new TypeError(`Method ${String(method)} is not a valid token`), + ); + throws( + () => new Router().off(method as string, one), + new TypeError(`Method ${String(method)} is not a valid token`), + ); + } // Never reaches the middlewares (a `connect` event of the server) + for (const method of ["CONNECT", "connect"]) { + throws( + () => new Router().on(method, one), + new TypeError("Method CONNECT is not supported"), + ); + throws( + () => new Router().off(method, one), + new TypeError("Method CONNECT is not supported"), + ); + } + }); + + test("constructor (an invalid `guarded`)", () => { throws( - () => new Router().on("CONNECT", one), - new TypeError("Method CONNECT is not supported"), - ); - throws( - () => new Router().off("CONNECT", one), - new TypeError("Method CONNECT is not supported"), + () => new Router({ guarded: 1 as unknown as boolean }), + new TypeError("`guarded` is not a boolean"), ); }); @@ -117,14 +139,9 @@ suite("Router", () => { deepStrictEqual(router.middlewares("UNSUBSCRIBE"), []); deepStrictEqual(router.methods, new Set()); - const method = "unsupported"; - - throws( - () => { - router.off(method); - }, - new TypeError(`Method ${method} is not supported`), - ); + throws(() => { + router.off("not valid"); + }, new TypeError("Method not valid is not a valid token")); }); for (const method of methods) { diff --git a/packages/binden/test/routing.test.ts b/packages/binden/test/routing.test.ts new file mode 100644 index 00000000..e4efd5ad --- /dev/null +++ b/packages/binden/test/routing.test.ts @@ -0,0 +1,560 @@ +import { deepStrictEqual, ok, throws } from "node:assert/strict"; +import { suite, test } from "node:test"; + +import { + Binden, + Context, + type IBindenOptions, + Middleware, + Router, + Stack, +} from "../index.js"; +import { compile } from "../src/route.js"; +import { layersOf } from "../src/stack.js"; +import { type IApp, type IListening, listen, send } from "./helpers.js"; + +/** Runs `fn` with a listening application */ +async function withApp( + app: IApp, + fn: (listening: IListening) => Promise, +): Promise { + const listening = await listen(app); + try { + await fn(listening); + } finally { + await listening.close(); + } +} + +/** Replies with the parameters and the mount path of the request */ +function echo(name = "echo") { + return (context: Context): Promise => + context.json({ + name, + params: context.params, + mount_path: context.mount_path, + }); +} + +/** The status and the JSON (or text) body of a request */ +async function get( + port: number, + path: string, + method = "GET", +): Promise<[number, unknown]> { + const { status, body, headers } = await send(port, { path, method }); + const json = headers["content-type"] === "application/json"; + return [status, json ? (JSON.parse(body) as unknown) : body]; +} + +class Noop extends Middleware { + // eslint-disable-next-line class-methods-use-this -- a middleware that does nothing + public run(): void { + // Nothing + } +} + +suite("Routing", () => { + suite("patterns", () => { + test("`route()` refuses an invalid pattern", () => { + const stack = new Stack(); + const invalid: [unknown, string][] = [ + ["users", "The route pattern does not start with `/`"], + [1, "The route pattern does not start with `/`"], + ["/:", "Invalid parameter name in /:"], + ["/*", "Invalid parameter name in /*"], + ["/:1a", "Invalid parameter name in /:1a"], + ["/:a-b", "Invalid parameter name in /:a-b"], + ["/:id/:id", "Duplicate parameter name in /:id/:id"], + ["/:id/*id", "Duplicate parameter name in /:id/*id"], + ["/*rest/x", "`*rest` is not the last segment"], + ["/a:b", "An unescaped `:` in /a:b"], + ["/file.*", "An unescaped `*` in /file.*"], + ["/a\\b", "Invalid escape in /a\\b"], + ["/a\\", "Invalid escape in /a\\"], + ]; + for (const [pattern, message] of invalid) { + throws( + () => stack.route(pattern as string, new Noop()), + new TypeError(message), + ); + // Even without items + throws(() => stack.route(pattern as string), new TypeError(message)); + } + throws( + () => stack.mount("/api/*rest", new Noop()), + new TypeError("A mount prefix has no `*` (/api/*rest)"), + ); + throws( + () => stack.mount("api"), + new TypeError("The mount pattern does not start with `/`"), + ); + throws( + () => stack.route("/", {} as Middleware), + new TypeError("Unsupported Middleware/Router"), + ); + throws( + () => stack.use("/", new Stack() as unknown as Middleware), + new TypeError("Unsupported Middleware/Router"), + ); + throws( + () => stack.mount("/", {} as Middleware), + new TypeError("Unsupported Middleware/Router/Stack"), + ); + deepStrictEqual(stack.stack, []); + }); + + test("`stack` lists the patterns of `route()` and `mount()`", () => { + const a = new Noop(); + const b = new Noop(); + const nested = new Stack(); + const stack = new Stack() + .route("/users/:id", a) + .route("/users/:id", b) + .mount("/api/", nested) + .mount("/api", a) + .mount("/", b) + .use(a) + // Nothing to add + .route("/x") + .mount("/x"); + deepStrictEqual(stack.stack, [ + [[a, b], { route: "/users/:id" }], + [[nested, a], { mount: "/api" }], + [[b], { mount: "" }], + [[a], null], + ]); + }); + }); + + suite("`route()`", () => { + test("matches a pattern, with its parameters", async () => { + const app = new Binden() + .route("/users/:id", echo("user")) + .route("/users/:id/posts/:post", echo("post")) + .route("/files/*path", echo("file")) + .route("/a\\:b/\\*", echo("escaped")) + .route("/", echo("root")); + await withApp(app, async ({ port }) => { + deepStrictEqual(await get(port, "/users/7"), [ + 200, + { name: "user", params: { id: "7" }, mount_path: "" }, + ]); + deepStrictEqual(await get(port, "/users/7/posts/x"), [ + 200, + { name: "post", params: { id: "7", post: "x" }, mount_path: "" }, + ]); + deepStrictEqual(await get(port, "/files"), [ + 200, + { name: "file", params: { path: "" }, mount_path: "" }, + ]); + deepStrictEqual(await get(port, "/files/a/b%20c/"), [ + 200, + { name: "file", params: { path: "a/b c/" }, mount_path: "" }, + ]); + deepStrictEqual((await get(port, "/a:b/*"))[1], { + name: "escaped", + params: {}, + mount_path: "", + }); + deepStrictEqual((await get(port, "/"))[1], { + name: "root", + params: {}, + mount_path: "", + }); + // A parameter is one non-empty segment + deepStrictEqual((await get(port, "/users"))[0], 404); + deepStrictEqual((await get(port, "/users/"))[0], 404); + deepStrictEqual((await get(port, "/users/7/x"))[0], 404); + deepStrictEqual((await get(port, "/users//posts/x"))[0], 404); + // The case of the literals (`case_sensitive`), and a trailing `/` (`strict`) + deepStrictEqual((await get(port, "/Users/7"))[0], 404); + deepStrictEqual((await get(port, "/users/7/"))[0], 404); + }); + }); + + test("decodes every parameter once", async () => { + const app = new Binden() + .route("/users/:id", echo()) + .route("/files/*path", echo()) + .route("/café/:x", echo()); + await withApp(app, async ({ port }) => { + const params = async (path: string): Promise => + ((await get(port, path))[1] as { params: unknown }).params; + deepStrictEqual(await params("/users/%41%20b"), { id: "A b" }); + deepStrictEqual(await params("/users/%2541"), { id: "%41" }); + deepStrictEqual(await params("/users/caf%C3%A9"), { id: "café" }); + deepStrictEqual(await params("/files/%2541/%41"), { path: "%41/A" }); + // A literal is compared once decoded + deepStrictEqual(await params("/caf%C3%A9/1"), { x: "1" }); + // A parameter named `__proto__` is a parameter + const proto = new Binden().route("/:__proto__", (context: Context) => + context.json({ own: Object.hasOwn(context.params, "__proto__") }), + ); + await withApp(proto, async (listening) => { + deepStrictEqual(await get(listening.port, "/x"), [ + 200, + { own: true }, + ]); + }); + }); + }); + + test("a parameter that cannot be decoded is `400 Bad Request`", async () => { + const app = new Binden() + .route("/users/:id/x", echo()) + .route("/files/*path", echo()); + await withApp(app, async ({ port }) => { + for (const path of [ + // Not UTF-8 + "/users/%FF/x", + // An encoded `/` or `\` would change the segments + "/users/a%2Fb/x", + "/users/a%2fb/x", + "/users/a%5Cb/x", + "/files/a/%2F", + "/files/%C3", + ]) { + deepStrictEqual((await get(port, path))[0], 400, path); + } + // Not when the other segments do not match + deepStrictEqual((await get(port, "/users/%FF/y"))[0], 404); + // A literal that cannot be decoded does not match + deepStrictEqual((await get(port, "/%FF/a"))[0], 404); + }); + }); + + test("`routing.case_sensitive: false` and `routing.strict: false`", async () => { + const app = new Binden({ + routing: { case_sensitive: false, strict: false }, + }) + .route("/users/:id", echo("user")) + .route("/items/", echo("items")) + .route("/files/*path", echo("file")) + .route("/", echo("root")) + .mount("/API", new Stack().route("/x", echo("api"))); + await withApp(app, async ({ port }) => { + const name = async (path: string): Promise => + ((await get(port, path))[1] as { name: string }).name; + deepStrictEqual(await name("/USERS/7"), "user"); + deepStrictEqual(await name("/users/7/"), "user"); + deepStrictEqual(await name("/items"), "items"); + deepStrictEqual(await name("/items/"), "items"); + deepStrictEqual(await name("/"), "root"); + deepStrictEqual(await name("/api/X"), "api"); + deepStrictEqual( + ((await get(port, "/files/a/")) as [number, { params: unknown }])[1] + .params, + { path: "a" }, + ); + // A parameter keeps its case, and only one trailing `/` is ignored + deepStrictEqual( + ((await get(port, "/Users/AbC")) as [number, { params: unknown }])[1] + .params, + { id: "AbC" }, + ); + deepStrictEqual((await get(port, "/users/7//"))[0], 404); + }); + }); + + test("`use()` keeps an exact path", async () => { + const app = new Binden({ + routing: { case_sensitive: false, strict: false }, + }).use("/users", echo("users")); + await withApp(app, async ({ port }) => { + deepStrictEqual((await get(port, "/users"))[0], 200); + deepStrictEqual((await get(port, "/users/"))[0], 404); + deepStrictEqual((await get(port, "/USERS"))[0], 404); + }); + }); + }); + + suite("`mount()`", () => { + test("the mounted items see the rest of the path", async () => { + const users = new Stack() + .use("/", echo("list")) + .route("/posts/:post", echo("post")) + .use(echo("rest")); + const app = new Binden() + .mount("/users/:id", users) + .mount("/static", echo("static")) + .use(echo("root")); + await withApp(app, async ({ port }) => { + deepStrictEqual(await get(port, "/users/7"), [ + 200, + { name: "list", params: { id: "7" }, mount_path: "/users/7" }, + ]); + deepStrictEqual((await get(port, "/users/7/"))[1], { + name: "list", + params: { id: "7" }, + mount_path: "/users/7", + }); + deepStrictEqual((await get(port, "/users/a%20b/posts/9"))[1], { + name: "post", + params: { id: "a b", post: "9" }, + mount_path: "/users/a%20b", + }); + deepStrictEqual((await get(port, "/users/7/x"))[1], { + name: "rest", + params: { id: "7" }, + mount_path: "/users/7", + }); + deepStrictEqual((await get(port, "/static/a/b"))[1], { + name: "static", + params: {}, + mount_path: "/static", + }); + // On a segment boundary only + deepStrictEqual((await get(port, "/staticx"))[1], { + name: "root", + params: {}, + mount_path: "", + }); + deepStrictEqual((await get(port, "/users"))[1], { + name: "root", + params: {}, + mount_path: "", + }); + }); + }); + + test("the level of a mount is left once its items are done", async () => { + const levels: unknown[] = []; + const record = (context: Context): void => { + levels.push([context.params, context.mount_path]); + }; + const shared = new Stack().route("/:x", record); + const app = new Binden() + .mount("/a/:id", shared) + .mount("/b", shared, new Stack().mount("/c", record)) + .use(record, (context: Context) => context.send()); + await withApp(app, async ({ port }) => { + deepStrictEqual((await get(port, "/a/1/2"))[0], 200); + deepStrictEqual((await get(port, "/b/3"))[0], 200); + deepStrictEqual((await get(port, "/b/c/d"))[0], 200); + }); + deepStrictEqual(levels, [ + [{ id: "1", x: "2" }, "/a/1"], + [{}, ""], + [{ x: "3" }, "/b"], + [{}, ""], + // `/c/d` is not one segment + [{}, "/b/c"], + [{}, ""], + ]); + }); + + test("a middleware of a mounted stack may replace the context", async () => { + let replaced: Context | null = null; + const nested = new Stack().use((context: Context) => { + replaced = new Context({ + request: context.request, + response: context.response, + }); + return replaced; + }); + const app = new Binden() + .mount("/", nested) + .use((context: Context) => + context.json({ same: context === replaced }), + ); + await withApp(app, async ({ port }) => { + deepStrictEqual(await get(port, "/"), [200, { same: true }]); + }); + }); + + test("a mounted stack ends the request once it is done", async () => { + let reached = false; + const app = new Binden() + .mount("/", new Stack().mount("/", echo("nested"))) + .use(() => { + reached = true; + }); + await withApp(app, async ({ port }) => { + deepStrictEqual( + ((await get(port, "/x")) as [number, { name: string }])[1].name, + "nested", + ); + }); + deepStrictEqual(reached, false); + }); + + test("an application, a stack in itself and other values cannot be mounted", () => { + const a = new Stack(); + const b = new Stack().mount("/a", a); + const c = new Stack().mount("/b", b); + const app = new Binden(); + throws( + () => a.mount("/x", app), + new TypeError("An application (`Binden`) cannot be mounted"), + ); + throws( + () => app.mount("/x", app), + new TypeError("An application (`Binden`) cannot be mounted"), + ); + throws( + () => a.mount("/x", a), + new TypeError("A stack cannot be mounted in itself"), + ); + // Through other stacks + throws( + () => a.mount("/x", c), + new TypeError("A stack cannot be mounted in itself"), + ); + throws( + () => a.mount("/x", b), + new TypeError("A stack cannot be mounted in itself"), + ); + // The same stack in several places is not a cycle + const shared = new Stack(); + const both = new Stack().mount("/1", shared).mount("/2", shared); + ok(new Stack().mount("/x", both, shared) instanceof Stack); + }); + + test("a stack mounted in itself fails the request", async () => { + const a = new Stack(); + const app = new Binden().mount("/a", a); + // A cycle that `mount()` would refuse + layersOf(a).push({ + kind: "mount", + pattern: compile("/a", "mount"), + items: [a], + }); + await withApp(app, async ({ port }) => { + deepStrictEqual((await get(port, "/a/a/a"))[0], 500); + }); + }); + }); + + suite("routers", () => { + test("a router handles a method in any case", async () => { + const app = new Binden().use( + new Router({ guarded: true }).on("propfind", (context: Context) => + context.status(207).send(), + ), + ); + await withApp(app, async ({ port }) => { + deepStrictEqual((await get(port, "/", "PROPFIND"))[0], 207); + }); + }); + + test("`OPTIONS` gets `204` with the methods of the guarded routers, unless one handles it", async () => { + const options = new Router({ guarded: true }) + .get(echo()) + .options((context: Context) => + context.setHeader("Allow", "GET, OPTIONS").status(200).send("own"), + ); + const app = new Binden() + .use( + "/auto", + new Router({ guarded: true }).get(echo()), + new Router({ guarded: true }).post(echo()), + ) + .use("/own", options) + .use("/open", new Router().get(echo())); + const noHead = new Binden({ auto_head: false }).use( + new Router({ guarded: true }).get(echo()), + ); + await withApp(app, async ({ port }) => { + const auto = await send(port, { path: "/auto", method: "OPTIONS" }); + deepStrictEqual( + [auto.status, auto.headers.allow, auto.body], + [204, "GET, HEAD, POST, OPTIONS", ""], + ); + const own = await send(port, { path: "/own", method: "OPTIONS" }); + deepStrictEqual([own.status, own.body], [200, "own"]); + // 405 with the same methods + const refused = await send(port, { path: "/auto", method: "DELETE" }); + deepStrictEqual( + [refused.status, refused.headers.allow], + [405, "GET, HEAD, POST, OPTIONS"], + ); + const ownRefused = await send(port, { path: "/own", method: "PUT" }); + deepStrictEqual( + [ownRefused.status, ownRefused.headers.allow], + [405, "GET, OPTIONS, HEAD"], + ); + // Without a guarded router + deepStrictEqual( + (await send(port, { path: "/open", method: "OPTIONS" })).status, + 404, + ); + }); + await withApp(noHead, async ({ port }) => { + const auto = await send(port, { method: "OPTIONS" }); + deepStrictEqual( + [auto.status, auto.headers.allow], + [204, "GET, OPTIONS"], + ); + }); + }); + }); + + suite("options", () => { + test("invalid options are refused", () => { + const invalid: [IBindenOptions, string][] = [ + [ + { auto_head: 1 as unknown as boolean }, + "`auto_head` is not a boolean", + ], + [ + { error_handler: "no" as unknown as () => void }, + "`error_handler` is not a function", + ], + [ + { state: {} as unknown as () => Record }, + "`state` is not a function", + ], + [{ routing: null as unknown as object }, "`routing` is not an object"], + [{ routing: "yes" as unknown as object }, "`routing` is not an object"], + [ + { routing: { case_sensitive: 0 as unknown as boolean } }, + "`routing.case_sensitive` is not a boolean", + ], + [ + { routing: { strict: "no" as unknown as boolean } }, + "`routing.strict` is not a boolean", + ], + ]; + for (const [options, message] of invalid) { + throws(() => new Binden(options), new TypeError(message)); + } + }); + + test("`state` creates the state of every request", async () => { + let count = 0; + const app = new Binden<{ count: number; seen: boolean }>({ + state: (): { count: number; seen: boolean } => { + count += 1; + return { count, seen: false }; + }, + }).use((context): Promise => { + const { state } = context; + const { seen } = state; + state.seen = true; + return context.json({ count: state.count, seen }); + }); + await withApp(app, async ({ port }) => { + deepStrictEqual(await get(port, "/"), [200, { count: 1, seen: false }]); + deepStrictEqual(await get(port, "/"), [200, { count: 2, seen: false }]); + }); + }); + + test("a `state` that fails, or that is not an object, fails the request", async () => { + const failing = new Binden({ + state: (): never => { + throw new Error("A failing state"); + }, + }).use(echo()); + const invalid = new Binden({ + state: (): Record => + null as unknown as Record, + }).use(echo()); + for (const app of [failing, invalid]) { + await withApp(app, async ({ port }) => { + deepStrictEqual((await get(port, "/"))[0], 500); + }); + } + }); + }); +}); diff --git a/packages/binden/test/types.test.ts b/packages/binden/test/types.test.ts new file mode 100644 index 00000000..c13b7df1 --- /dev/null +++ b/packages/binden/test/types.test.ts @@ -0,0 +1,115 @@ +// Type tests: compiled by `tsc` (the build of the package), where every +// `@ts-expect-error` must be an error. The test only checks that the typed +// values can be created. +import { ok } from "node:assert/strict"; +import { test } from "node:test"; + +import { + Binden, + type Context, + type IBindenOptions, + type IRouteParams, + Middleware, + Router, + Stack, +} from "../index.js"; + +interface IUser { + user: string; +} + +type IOptional = Partial; + +/** A middleware of another package: a class that is not typed for a state */ +class Untyped extends Middleware { + // eslint-disable-next-line class-methods-use-this -- a middleware that does nothing + public run(): void { + // Nothing + } +} + +/** Checks that a value has the type `T` */ +function is(value: T): T { + return value; +} + +const state = (): IUser => ({ user: "alice" }); + +test("types", () => { + // A state with required fields needs its factory + const app = new Binden({ state }); + // @ts-expect-error -- no factory + is(new Binden()); + // @ts-expect-error -- no factory + is(new Binden({})); + // @ts-expect-error -- a factory of another state + is(new Binden({ state: (): { name: string } => ({ name: "alice" }) })); + // Not with optional fields only + is(new Binden()); + is(new Binden()); + const options: IBindenOptions = { + state, + error_handler: (context): void => { + is(context.state.user); + }, + }; + is(new Binden(options)); + + // The parameters of a pattern + app.route("/users/:id/files/*path", (context) => { + is(context.params.id); + is(context.params.path); + is(context.state.user); + // @ts-expect-error -- not a parameter of the pattern + is(context.params.other); + }); + app.route("/a/\\:literal/:b", (context) => { + is(context.params.b); + // @ts-expect-error -- an escaped `:` is literal + is(context.params.literal); + }); + is>({ id: "1" }); + // @ts-expect-error -- a missing parameter + is>({}); + // A pattern that is not a literal type: any parameters + const dynamic: string = "/x"; + app.route(dynamic, (context) => { + is(context.params.anything); + }); + + // A middleware typed for parameters needs them + const byId = (context: Context): void => { + is(context.params.id); + }; + app.route("/users/:id", byId); + // @ts-expect-error -- no `id` in `use()` + app.use(byId); + // @ts-expect-error -- no `id` in this pattern + app.route("/users/:name", byId); + + // A middleware typed for another state + const other = (context: Context<{ other: number }>): void => { + is(context.state.other); + }; + // @ts-expect-error -- another state + app.use(other); + + // Routers and stacks of the state + const router = new Router().get((context) => { + is(context.state.user); + is(context.params.id); + }); + app.route("/routers/:id", router); + const stack = new Stack().use((context) => { + is(context.state.user); + }); + app.mount("/stack", stack); + // @ts-expect-error -- a stack of another state + app.mount("/other", new Stack<{ other: number }>()); + + // Middlewares that are not typed for a state are used with any state + app.use(new Untyped()); + app.route("/untyped/:id", new Untyped(), new Router().get(new Untyped())); + app.mount("/untyped", new Untyped()); + ok(app instanceof Binden); +}); From 5d2bf9f8da2598cf9642c10aa2ccfa8c5a149f49 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:32:14 +0300 Subject: [PATCH 141/675] feat: add graceful shutdown and server registration in binden A SIGTERM handler had no way to stop a server without cutting the requests in progress, nor to bound how long it waits for them. `shutdown(server, { timeout, cleanup_timeout })` stops accepting connections, closes the idle ones (and every other one once its response is complete), adds `Connection: close` to the responses whose headers are not sent yet, and answers a request that still arrives on an open connection with 503 and `Connection: close` without running the stack. Once `timeout` has passed, the requests left are aborted (`shutdown`), and every connection is closed, including the upgraded and tunnelled ones that the server tracks since its registration. The promise resolves once the server has closed and the cleanups of its requests have run, and rejects (and logs) when they take longer than `cleanup_timeout`; another call returns the same promise. `isClosing(server)` tells whether it has started. `createServer()` and `createSecureServer()` register their server, and `serverOptions()` with `callback(server)` serve the requests of a server created elsewhere: a request of foreign classes is logged and its connection closed. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 44 +++ packages/binden/index.ts | 6 + packages/binden/src/binden.ts | 134 +++++--- packages/binden/src/delay.ts | 14 + packages/binden/src/shutdown.ts | 201 ++++++++++++ packages/binden/test/fixtures/sigterm.ts | 49 +++ packages/binden/test/helpers.ts | 13 + packages/binden/test/process.test.ts | 8 + packages/binden/test/routing.test.ts | 11 +- packages/binden/test/shutdown.test.ts | 386 +++++++++++++++++++++++ 10 files changed, 820 insertions(+), 46 deletions(-) create mode 100644 packages/binden/src/delay.ts create mode 100644 packages/binden/src/shutdown.ts create mode 100644 packages/binden/test/fixtures/sigterm.ts create mode 100644 packages/binden/test/shutdown.test.ts diff --git a/packages/binden/README.md b/packages/binden/README.md index cc519a1f..23783c09 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -45,6 +45,20 @@ const app = new Binden().use("/path", middleware).use("/path2", router); const secureServer = app.createSecureServer({ key, cert }); ``` +- `.serverOptions()` and `.callback(server)` - the request and response classes of the application, and the request listener of a server created with them (e.g. a server of another library). `.callback(server)` registers the server for [`shutdown()`](#shutdown) at once; a request of a server created without these classes is logged (`error`), and its connection closed. HTTP/2 is not supported: use a reverse proxy in front. + +```typescript +import { createServer } from "node:http"; +import { Binden } from "binden"; + +const app = new Binden().use(middleware); +const server = createServer({ + ...app.serverOptions(), + keepAliveTimeout: 65_000, +}); +server.on("request", app.callback(server)); +``` + The state of a request is typed by the application, and so is every middleware added to it: ```typescript @@ -181,6 +195,36 @@ const app = new Binden({ timeout: 30_000 }).use(async (context: Context) => { }); ``` +### Shutdown + +`shutdown(server, { timeout = 10000, cleanup_timeout = 1000 })` closes a server gracefully (a server created by `createServer()`, `createSecureServer()` or registered by `callback()`): + +1. at once, `isClosing(server)` becomes `true`, the server stops accepting connections, the idle connections are closed (and every other one as soon as it is idle), and the responses in progress get `Connection: close` when their headers have not been sent yet. A request that still arrives on an open connection gets `503 Service Unavailable` with `Connection: close`, without running the stack; +2. the requests in progress have `timeout` milliseconds to complete; +3. then, at once, their signal is aborted (`shutdown`), and every connection is closed, including those that have been upgraded (e.g. WebSocket) or tunnelled (`CONNECT`); +4. the promise resolves once the server has closed and the cleanups of the requests (see `context.onCleanup()`) have run. Cleanups that take longer than `cleanup_timeout` milliseconds once the server has closed reject it (and are logged). + +Another call returns the same promise. A server that is not listening (before `listen()`, or once closed) only waits for the cleanups of its requests. + +```typescript +import { Binden, shutdown } from "binden"; + +const app = new Binden({ timeout: 30_000 }).use(middleware); +const server = app.createServer().listen(8080); + +process.once("SIGTERM", () => { + shutdown(server, { timeout: 10_000 }).then( + () => { + process.exitCode = 0; + }, + (error: unknown) => { + process.stderr.write(`${String(error)}\n`); + process.exitCode = 1; + }, + ); +}); +``` + ### Context - `.log` - get the logger (see [`@binden/logger`](https://github.com/binden-js/binden/tree/main/packages/logger)) diff --git a/packages/binden/index.ts b/packages/binden/index.ts index d5111264..137324f4 100644 --- a/packages/binden/index.ts +++ b/packages/binden/index.ts @@ -20,6 +20,12 @@ export * from "./src/response.js"; export type { IRedirectOptions, IRedirectStatus } from "./src/redirect.js"; export type { ISendFileOptions } from "./src/send-file.js"; export * from "./src/router.js"; +export { + isClosing, + shutdown, + type IServer, + type IShutdownOptions, +} from "./src/shutdown.js"; export type { IRouteParams, IRoutingOptions } from "./src/route.js"; export { Stack, diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index 935dca58..64f81c99 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -3,6 +3,7 @@ import https from "node:https"; import type { ILogger } from "@binden/logger"; import { Context } from "./context.js"; import { BindenError } from "./error.js"; +import { validateDelay } from "./delay.js"; import { BindenAbortError, Exchange } from "./exchange.js"; import defaultLogger from "./logger.js"; import { Middleware, type IMiddleware } from "./middleware.js"; @@ -11,6 +12,7 @@ import { BindenRequest, type ITrustProxy } from "./request.js"; import { BindenResponse, ct_json } from "./response.js"; import { matchPrefix, matchRoute, type IRoutingOptions } from "./route.js"; import { Router } from "./router.js"; +import { isClosing, register, track } from "./shutdown.js"; import { type ILayer, layersOf, markRoot, Stack } from "./stack.js"; import { isStatus } from "./status.js"; @@ -72,9 +74,6 @@ export type IBindenOptions> = ? { state?: (() => State) | undefined } : { state: () => State }); -/** The greatest delay of a Node.js timer */ -const MAX_DELAY = 2 ** 31 - 1; - /** Thrown through the stack once the request has been aborted: the stack stops */ const ABORTED = new Error("The request has been aborted"); @@ -141,8 +140,8 @@ export class Binden< ) { throw new RangeError("`trust_proxy` is not a non-negative integer"); } - Binden.#delay("timeout", timeout, 0); - Binden.#delay("error_handler_timeout", error_handler_timeout, 1); + validateDelay("timeout", timeout, 0); + validateDelay("error_handler_timeout", error_handler_timeout, 1); Binden.#boolean("auto_head", auto_head); if ( typeof error_handler !== "undefined" && @@ -175,55 +174,95 @@ export class Binden< }; } - /** Creates an HTTP server that handles its requests with the application */ + /** + * Creates an HTTP server that handles its requests with the application + * (registered for `shutdown()`) + */ public createServer( options: Omit< http.ServerOptions, "IncomingMessage" | "ServerResponse" > = {}, ): http.Server { - return http.createServer( - { - ...options, - IncomingMessage: this.#Request, - ServerResponse: BindenResponse, - }, - (request, response) => { - this.#requestListener(request, response); - }, - ); + const server = http.createServer({ ...options, ...this.serverOptions() }); + server.on("request", this.callback(server)); + return server; } - /** Creates an HTTPS server that handles its requests with the application */ + /** + * Creates an HTTPS server that handles its requests with the application + * (registered for `shutdown()`) + */ public createSecureServer( options: Omit< https.ServerOptions, "IncomingMessage" | "ServerResponse" > = {}, ): https.Server { - return https.createServer( - { - ...options, - IncomingMessage: this.#Request, - ServerResponse: BindenResponse, - }, - (request, response) => { - this.#requestListener(request, response); - }, - ); + const server = https.createServer({ ...options, ...this.serverOptions() }); + server.on("request", this.callback(server)); + return server; + } + + /** + * The request and response classes of the application, for a server that + * `callback()` handles the requests of (e.g. `http2` is not supported) + */ + public serverOptions(): { + IncomingMessage: typeof BindenRequest; + ServerResponse: typeof BindenResponse; + } { + return { IncomingMessage: this.#Request, ServerResponse: BindenResponse }; + } + + /** + * The request listener of a server created with `serverOptions()`, which + * is registered for `shutdown()` at once. A request of another kind (e.g. of + * a server created without them) is logged, and its connection closed. + */ + public callback( + server: http.Server | https.Server, + ): (request: http.IncomingMessage, response: http.ServerResponse) => void { + register(server, (message, error) => { + this.#logRoot("error", message, { error }); + }); + return (request, response): void => { + if ( + !(request instanceof this.#Request) || + !(response instanceof BindenResponse) + ) { + const message = + "The request and the response are not those of the application (`serverOptions()`)"; + this.#logRoot("error", message, {}); + request.socket.destroy(); + return; + } + this.#requestListener( + server, + request, + response as BindenResponse, + ); + }; } #requestListener( + server: http.Server | https.Server, request: BindenRequest, response: BindenResponse, ): void { const { state, failure } = this.#createState(); const exchange = Exchange.for(request, response, { state }); + track(server, exchange, response); const context = new Context({ request, response, logger: this.#logger }); if (this.#timeout > 0) { exchange.setTimeout(this.#timeout); } - this.#run(context, exchange, failure).catch((error: unknown) => { + // A request on a connection that was open once the server is closing + // gets 503: the stack does not run + const handled = isClosing(server) + ? Binden.#closing(context) + : this.#run(context, exchange, failure); + handled.catch((error: unknown) => { // Unexpected (every failure is handled): the process must not crash Binden.#log(context, "error", "The request has failed", { error }); response.destroy(); @@ -566,9 +605,20 @@ export class Binden< for (const name of [...Binden.#representation, "Content-Type"]) { response.removeHeader(name); } - response.status(DefaultErrorCode); + await Binden.#empty(context, DefaultErrorCode); + } + + /** `503 Service Unavailable` to a request that arrives once closing */ + static async #closing(context: Context): Promise { + context.response.setHeader("Connection", "close"); + await Binden.#empty(context, 503); + } + + /** An empty reply of the framework (a failure to send it is logged) */ + static async #empty(context: Context, status: number): Promise { + context.response.status(status); try { - await response.send(); + await context.response.send(); } catch { Binden.#log(context, "debug", "The error reply has not been sent", { context, @@ -733,6 +783,19 @@ export class Binden< "Transfer-Encoding", ]; + /** Logs with the logger of the application (never throws) */ + #logRoot( + level: ILogLevel, + message: string, + extra: Record, + ): void { + try { + this.#logger[level](message, extra); + } catch { + // Nothing else to report it to + } + } + /** Framework logs never break the handling of a request (e.g. a failing serializer) */ static #log( context: Context, @@ -747,17 +810,6 @@ export class Binden< } } - /** A delay of a timer: an integer from `min` to the greatest delay of Node.js */ - static #delay(name: string, value: unknown, min: number): void { - if (typeof value !== "number" || !Number.isInteger(value)) { - throw new TypeError(`\`${name}\` is not an integer`); - } else if (value < min || value > MAX_DELAY) { - throw new RangeError( - `\`${name}\` is not between ${min} and ${MAX_DELAY}`, - ); - } - } - /** * The state of a request, from the `state` option: a factory that fails (or * that does not return an object) fails the request diff --git a/packages/binden/src/delay.ts b/packages/binden/src/delay.ts new file mode 100644 index 00000000..1aed63a9 --- /dev/null +++ b/packages/binden/src/delay.ts @@ -0,0 +1,14 @@ +/** The greatest delay of a Node.js timer */ +export const MAX_DELAY = 2 ** 31 - 1; + +/** + * Checks the delay of a timer option: an integer (a `TypeError` otherwise) + * from `min` to the greatest delay of Node.js (a `RangeError` otherwise) + */ +export function validateDelay(name: string, value: unknown, min: number): void { + if (typeof value !== "number" || !Number.isInteger(value)) { + throw new TypeError(`\`${name}\` is not an integer`); + } else if (value < min || value > MAX_DELAY) { + throw new RangeError(`\`${name}\` is not between ${min} and ${MAX_DELAY}`); + } +} diff --git a/packages/binden/src/shutdown.ts b/packages/binden/src/shutdown.ts new file mode 100644 index 00000000..216704a6 --- /dev/null +++ b/packages/binden/src/shutdown.ts @@ -0,0 +1,201 @@ +import type { Server as HttpServer, ServerResponse } from "node:http"; +import type { Server as HttpsServer } from "node:https"; +import type { Socket } from "node:net"; +import { validateDelay } from "./delay.js"; +import type { Exchange } from "./exchange.js"; + +/** A server that `shutdown()` closes */ +export type IServer = HttpServer | HttpsServer; + +/** The options of `shutdown()` */ +export interface IShutdownOptions { + /** + * The milliseconds that the requests in progress have to complete + * (`10000`): then they are aborted (`shutdown`) and every connection closed + */ + timeout?: number | undefined; + /** + * The milliseconds that the cleanups of the requests have to complete once + * the server has closed (`1000`): then `shutdown()` rejects + */ + cleanup_timeout?: number | undefined; +} + +/** Reports a failure of a shutdown (e.g. to the logger of the application) */ +export type IShutdownReport = (message: string, error: unknown) => void; + +/** What is known of a server: its connections and requests, and its shutdown */ +interface IRegistration { + closing: boolean; + promise: Promise | null; + report: IShutdownReport | null; + readonly sockets: Set; + /** The requests in progress, with their response */ + readonly exchanges: Map; + /** Called once no connection is left */ + readonly drained: (() => void)[]; +} + +const registrations = new WeakMap(); + +/** + * Registers a server (once): its connections are tracked from now on, + * including those that are upgraded (e.g. WebSocket) or tunnelled (`CONNECT`) + */ +export function register( + server: IServer, + report: IShutdownReport | null = null, +): IRegistration { + const found = registrations.get(server); + if (found) { + found.report ??= report; + return found; + } + const registration: IRegistration = { + closing: false, + promise: null, + report, + sockets: new Set(), + exchanges: new Map(), + drained: [], + }; + registrations.set(server, registration); + server.on("connection", (socket: Socket) => { + registration.sockets.add(socket); + socket.once("close", () => { + registration.sockets.delete(socket); + if (registration.sockets.size === 0) { + for (const drained of registration.drained.splice(0)) { + drained(); + } + } + }); + }); + return registration; +} + +/** + * Tracks a request of a server until its cleanups have run: once the server + * is closing, its connection is closed as soon as it is idle + */ +export function track( + server: IServer, + exchange: Exchange, + response: ServerResponse, +): void { + const registration = register(server); + registration.exchanges.set(exchange, response); + response.once("close", () => { + if (registration.closing) { + // Once the connection is idle (e.g. kept alive after its response) + setImmediate(() => { + server.closeIdleConnections(); + }); + } + }); + const forget = (): void => { + registration.exchanges.delete(exchange); + }; + // Settles once the cleanups have run (it never rejects) + exchange.finished.then(forget, forget); +} + +/** Whether `shutdown()` has been called for a server */ +export function isClosing(server: IServer): boolean { + return registrations.get(server)?.closing ?? false; +} + +/** The shutdown of a server */ +async function close( + server: IServer, + registration: IRegistration, + timeout: number, + cleanup_timeout: number, +): Promise { + registration.closing = true; + for (const response of registration.exchanges.values()) { + if (!response.headersSent) { + // The connection closes once the response is complete + response.setHeader("Connection", "close"); + } + } + + // Before `listen()` (or once closed), there is nothing to close but the + // connections that are left + const closed = server.listening + ? new Promise((resolve) => { + server.once("close", resolve); + }) + : new Promise((resolve) => { + if (registration.sockets.size === 0) { + resolve(); + } else { + registration.drained.push(resolve); + } + }); + if (server.listening) { + server.close(); + } + server.closeIdleConnections(); + + const timer = setTimeout(() => { + for (const exchange of registration.exchanges.keys()) { + exchange.abort("shutdown"); + } + server.closeAllConnections(); + for (const socket of registration.sockets) { + socket.destroy(); + } + }, timeout); + try { + await closed; + } finally { + clearTimeout(timer); + } + + // The cleanups run once the responses are closed: they must not take long + const { promise: expired, resolve } = Promise.withResolvers(); + const cleanup = setTimeout(resolve, cleanup_timeout, false); + const done = await Promise.race([ + Promise.all( + [...registration.exchanges.keys()].map( + async (exchange) => exchange.finished, + ), + ).then(() => true), + expired, + ]); + clearTimeout(cleanup); + if (!done) { + const error = new Error( + `The cleanup of the requests has not completed within ${cleanup_timeout} ms`, + ); + // The report of an application does not throw + registration.report?.("The shutdown has failed", error); + throw error; + } +} + +/** + * Closes a server gracefully: it stops accepting connections, closes those + * that are idle (and every other one once its response is complete), and + * lets the requests in progress complete within `timeout`, after which they + * are aborted (`shutdown`) and every connection is closed. Resolves once the + * server has closed and the cleanups of its requests have run, or rejects + * when they take longer than `cleanup_timeout`. Another call returns the same + * promise. + */ +export function shutdown( + server: IServer, + { timeout = 10_000, cleanup_timeout = 1000 }: IShutdownOptions = {}, +): Promise { + validateDelay("timeout", timeout, 1); + validateDelay("cleanup_timeout", cleanup_timeout, 1); + const registration = register(server); + registration.promise ??= close( + server, + registration, + timeout, + cleanup_timeout, + ); + return registration.promise; +} diff --git a/packages/binden/test/fixtures/sigterm.ts b/packages/binden/test/fixtures/sigterm.ts new file mode 100644 index 00000000..8b6cb920 --- /dev/null +++ b/packages/binden/test/fixtures/sigterm.ts @@ -0,0 +1,49 @@ +// Run by `process.test.ts`: on SIGTERM, `shutdown()` lets the request in +// progress complete (its connection then closes), and the process exits once +// it has resolved, with nothing left to keep it alive +import { type IncomingMessage, request } from "node:http"; +import type { AddressInfo } from "node:net"; +import { once } from "node:events"; +import { Binden, shutdown } from "../../index.js"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const app = new Binden().use(async (context) => { + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + await context.send("done"); +}); + +const server = app.createServer().listen(0); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const closing = new Promise((resolve, reject) => { + process.once("SIGTERM", () => { + shutdown(server).then(resolve, reject); + }); +}); + +const pending = new Promise((resolve, reject) => { + request({ agent: false, port }, resolve).on("error", reject).end(); +}); +await new Promise((resolve) => { + setTimeout(resolve, 20); +}); +process.kill(process.pid, "SIGTERM"); + +const response = await pending; +let body = ""; +response.setEncoding("utf8").on("data", (chunk: string) => { + body += chunk; +}); +await once(response, "end"); +await closing; +const { statusCode, headers } = response; +process.stdout.write( + `${statusCode} ${headers.connection} ${body} ${server.listening}\n`, +); diff --git a/packages/binden/test/helpers.ts b/packages/binden/test/helpers.ts index 451f2c0a..3868253f 100644 --- a/packages/binden/test/helpers.ts +++ b/packages/binden/test/helpers.ts @@ -17,6 +17,19 @@ export function sleep(ms: number): Promise { }); } +/** The result of a promise, which must settle within `ms` milliseconds */ +export async function within(promise: Promise, ms = 2000): Promise { + const { promise: expired, reject } = Promise.withResolvers(); + const timer = setTimeout(() => { + reject(new Error(`Not settled within ${ms} ms`)); + }, ms); + try { + return await Promise.race([promise, expired]); + } finally { + clearTimeout(timer); + } +} + /** A promise that never settles (e.g. a middleware that hangs) */ export function never(): Promise { return new Promise(() => { diff --git a/packages/binden/test/process.test.ts b/packages/binden/test/process.test.ts index 9c72114a..80210f72 100644 --- a/packages/binden/test/process.test.ts +++ b/packages/binden/test/process.test.ts @@ -42,6 +42,14 @@ suite("Process safety", () => { }); }); + test("SIGTERM shuts the server down gracefully", () => { + deepStrictEqual(run("sigterm.ts"), { + status: 0, + stdout: "200 close done false\n", + stderr: "", + }); + }); + test("a logger whose `child()` throws does not crash the process", () => { deepStrictEqual(run("throwing-logger.ts"), { status: 0, diff --git a/packages/binden/test/routing.test.ts b/packages/binden/test/routing.test.ts index e4efd5ad..7354d86a 100644 --- a/packages/binden/test/routing.test.ts +++ b/packages/binden/test/routing.test.ts @@ -353,11 +353,12 @@ suite("Routing", () => { }); return replaced; }); - const app = new Binden() - .mount("/", nested) - .use((context: Context) => - context.json({ same: context === replaced }), - ); + const app = new Binden().mount( + "/", + nested, + // In the same layer as the mounted stack + (context: Context) => context.json({ same: context === replaced }), + ); await withApp(app, async ({ port }) => { deepStrictEqual(await get(port, "/"), [200, { same: true }]); }); diff --git a/packages/binden/test/shutdown.test.ts b/packages/binden/test/shutdown.test.ts new file mode 100644 index 00000000..da7dcafc --- /dev/null +++ b/packages/binden/test/shutdown.test.ts @@ -0,0 +1,386 @@ +import { deepStrictEqual, ok, rejects, throws } from "node:assert/strict"; +import { once } from "node:events"; +import { createServer, type Server } from "node:http"; +import { connect, type Socket } from "node:net"; +import { suite, test } from "node:test"; +import type { Logger } from "@binden/logger"; + +import { + Binden, + BindenAbortError, + BindenRequest, + BindenResponse, + type Context, + isClosing, + shutdown, +} from "../index.js"; +import { capture, listen, never, send, sleep, within } from "./helpers.js"; + +/** A connection to a server, with everything it has received */ +interface IConnection { + readonly socket: Socket; + received(): string; + /** Resolves once the received data includes `text` */ + waitFor(text: string): Promise; + /** Resolves once the server has closed the connection */ + closed: Promise; +} + +/** Opens a connection to a server (closed by the test at the latest) */ +async function connection(port: number): Promise { + const socket = connect(port); + await once(socket, "connect"); + let data = ""; + socket.setEncoding("utf8").on("data", (chunk: string) => { + data += chunk; + }); + socket.on("error", () => { + // Reset by the server + }); + return { + socket, + received: (): string => data, + waitFor: async (text: string): Promise => { + const deadline = Date.now() + 5000; + while (!data.includes(text)) { + if (Date.now() > deadline || socket.destroyed) { + throw new Error(`Not received: ${text} (${data})`); + } + await sleep(5); + } + }, + closed: once(socket, "close").then(() => { + // Closed + }), + }; +} + +/** A promise that the test resolves */ +function gate(): { promise: Promise; open(): void } { + const { promise, resolve } = Promise.withResolvers(); + return { + promise: promise.then(() => { + // Opened + }), + open: (): void => { + resolve(null); + }, + }; +} + +suite("shutdown()", () => { + test("validates its options", () => { + const server = createServer(); + throws( + () => shutdown(server, { timeout: 0 }), + new RangeError("`timeout` is not between 1 and 2147483647"), + ); + throws( + () => shutdown(server, { cleanup_timeout: 1.5 }), + new TypeError("`cleanup_timeout` is not an integer"), + ); + throws( + () => shutdown(server, { timeout: 2 ** 31 }), + new RangeError("`timeout` is not between 1 and 2147483647"), + ); + ok(!isClosing(server)); + }); + + test("closes an idle server at once, and returns the same promise", async () => { + const app = new Binden().use((context: Context) => context.send("ok")); + const { server, port } = await listen(app); + const idle = await connection(port); + idle.socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await idle.waitFor("\r\n\r\nok"); + + ok(!isClosing(server)); + const first = shutdown(server); + ok(isClosing(server)); + ok(shutdown(server, { timeout: 1 }) === first); + await within(first); + // The idle keep-alive connection has been closed, and no other is accepted + await within(idle.closed); + ok(!server.listening); + await rejects(send(port), { code: "ECONNREFUSED" }); + }); + + test("lets a request complete, and closes its connection afterwards", async () => { + const started = gate(); + const release = gate(); + let aborted = true; + const app = new Binden().use(async (context: Context) => { + started.open(); + await release.promise; + ({ aborted } = context.signal); + await context.send("done"); + }); + const { server, port } = await listen(app); + const client = await connection(port); + client.socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await started.promise; + + const closing = shutdown(server); + await sleep(20); + release.open(); + await within(closing); + await within(client.closed); + ok(!aborted); + ok(client.received().startsWith("HTTP/1.1 200 OK"), client.received()); + ok( + /\r\nConnection: close\r\n/iu.test(client.received()), + client.received(), + ); + ok(client.received().endsWith("done")); + }); + + test("a request that arrives on a connection once closing gets `503`", async () => { + const release = gate(); + const paths: string[] = []; + const app = new Binden().use(async (context: Context) => { + paths.push(context.url.pathname); + // The headers are sent before the shutdown: the connection stays open + context.response.writeHead(200, { "Content-Length": 1 }); + context.response.flushHeaders(); + await release.promise; + context.response.end("a"); + context.done = true; + }); + const { server, port } = await listen(app); + const client = await connection(port); + client.socket.write("GET /a HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await client.waitFor("Content-Length: 1\r\n"); + + const closing = shutdown(server); + // Pipelined after the first one, on its connection + client.socket.write("GET /b HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await sleep(20); + release.open(); + await within(closing); + await within(client.closed); + const [, second = ""] = client.received().split(/(?=HTTP\/1\.1 )/u); + ok(second.startsWith("HTTP/1.1 503"), client.received()); + ok(/\r\nConnection: close\r\n/iu.test(second), second); + deepStrictEqual(paths, ["/a"]); + }); + + test("closes a connection that is kept alive once its response is complete", async () => { + const release = gate(); + const app = new Binden().use(async (context: Context) => { + // The headers are sent before the shutdown: they keep the connection alive + context.response.writeHead(200, { "Content-Length": 1 }); + context.response.flushHeaders(); + await release.promise; + context.response.end("a"); + context.done = true; + }); + const { server, port } = await listen(app); + const client = await connection(port); + client.socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await client.waitFor("Content-Length: 1\r\n"); + ok(/\r\nConnection: keep-alive\r\n/iu.test(client.received())); + + const before = Date.now(); + const closing = shutdown(server); + release.open(); + await within(closing); + await within(client.closed); + // Not after the keep-alive timeout of the server + ok(Date.now() - before < 1000); + }); + + test("aborts the requests that take longer than `timeout`", async () => { + const started = gate(); + let reason: unknown = null; + const app = new Binden().use(async (context: Context) => { + context.signal.addEventListener("abort", () => { + ({ reason } = context.signal.reason as BindenAbortError); + }); + started.open(); + await never(); + }); + const { server, port } = await listen(app); + // The connection is closed + const response = rejects(send(port)); + await started.promise; + + const before = Date.now(); + await within(shutdown(server, { timeout: 50 })); + ok(Date.now() - before >= 40); + deepStrictEqual(reason, "shutdown"); + await response; + }); + + test("closes the upgraded connections once `timeout` has passed", async () => { + const app = new Binden(); + const { server, port } = await listen(app); + server.on("upgrade", (_request, socket: Socket) => { + socket.write( + "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n", + ); + }); + const client = await connection(port); + client.socket.write( + "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n", + ); + await client.waitFor("101 Switching Protocols"); + + await within(shutdown(server, { timeout: 30 })); + await within(client.closed); + }); + + test("rejects when the cleanups take longer than `cleanup_timeout`", async () => { + const captured = capture(); + const app = new Binden({ logger: captured.logger }).use( + (context: Context) => { + context.onCleanup(never); + return context.send("ok"); + }, + ); + const { server, port } = await listen(app); + deepStrictEqual((await send(port)).body, "ok"); + const kept = await connection(port); + kept.socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await kept.waitFor("\r\n\r\nok"); + + await rejects( + within(shutdown(server, { cleanup_timeout: 30 })), + new Error("The cleanup of the requests has not completed within 30 ms"), + ); + await captured.waitFor("The shutdown has failed"); + }); + + test("before `listen()`, and once closed, waits for the cleanups only", async () => { + const app = new Binden(); + const unused = app.createServer(); + await within(shutdown(unused)); + ok(isClosing(unused) && !unused.listening); + + const closed = await listen(app); + await closed.close(); + await within(shutdown(closed.server)); + ok(isClosing(closed.server)); + }); + + test("once closed, waits for the connections that are left", async () => { + const release = gate(); + const app = new Binden().use(async (context: Context) => { + await release.promise; + await context.send("done"); + }); + const { server, port } = await listen(app); + const client = await connection(port); + client.socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await sleep(20); + // Not listening, with a connection left + server.close(); + const closing = shutdown(server); + release.open(); + await within(closing); + await within(client.closed); + ok(client.received().endsWith("done")); + ok(/\r\nConnection: close\r\n/iu.test(client.received())); + }); + + test("closes one server of an application, not the others", async () => { + const app = new Binden().use((context: Context) => context.send("ok")); + const one = await listen(app); + const other = await listen(app); + try { + await within(shutdown(one.server)); + ok(!isClosing(other.server)); + deepStrictEqual((await send(other.port)).body, "ok"); + } finally { + await other.close(); + } + }); + + test("closes a server that is not registered", async () => { + const server: Server = createServer((_request, response) => { + response.end("ok"); + }); + server.listen(0); + await once(server, "listening"); + await within(shutdown(server)); + ok(!server.listening); + }); +}); + +suite("callback()", () => { + test("handles the requests of a server created with `serverOptions()`", async () => { + const app = new Binden().use((context: Context) => + context.send(String(context.request instanceof BindenRequest)), + ); + const options = app.serverOptions(); + ok(options.ServerResponse === BindenResponse); + ok( + new Binden().serverOptions().IncomingMessage !== options.IncomingMessage, + ); + const server = createServer(options); + server.on("request", app.callback(server)); + server.listen(0); + await once(server, "listening"); + const { port } = server.address() as { port: number }; + try { + deepStrictEqual((await send(port)).body, "true"); + // The server is registered at once + const client = await connection(port); + client.socket.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await client.waitFor("\r\n\r\ntrue"); + await within(shutdown(server)); + await within(client.closed); + } finally { + server.closeAllConnections(); + server.close(); + } + }); + + test("closes the connection of a request of a foreign server", async () => { + const captured = capture(); + const app = new Binden({ logger: captured.logger }); + // Neither the request nor the response of the application + const other = new Binden().serverOptions(); + for (const options of [ + {}, + { IncomingMessage: other.IncomingMessage }, + // The response of the application, the request of another one + { ...other, ServerResponse: BindenResponse }, + ]) { + const server = createServer(options); + server.on("request", app.callback(server)); + server.listen(0); + await once(server, "listening"); + const { port } = server.address() as { port: number }; + try { + await rejects(send(port), { code: "ECONNRESET" }); + } finally { + server.close(); + } + } + await captured.waitFor( + "The request and the response are not those of the application (`serverOptions()`)", + ); + + // Even with a logger that fails + const failing = new Binden({ + logger: { + ...captured.logger, + error: (): never => { + throw new Error("A failing logger"); + }, + child: (): never => { + throw new Error("A failing logger"); + }, + } as unknown as Logger, + }); + const server = createServer(); + server.on("request", failing.callback(server)); + server.listen(0); + await once(server, "listening"); + const { port } = server.address() as { port: number }; + try { + await rejects(send(port), { code: "ECONNRESET" }); + } finally { + server.close(); + } + }); +}); From e52bd3c63109bcac102bd391435037b32f19a07d Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:36:23 +0300 Subject: [PATCH 142/675] feat!: add safe default serializers and logger controls in logger The default serializers of pino log a whole error (its message and stack may quote the input of a request), and a request or response with its headers, which carry credentials. The default instance was also created with its pino logger when the package was imported. The default serializers now keep an allowlist, found by duck typing so that the package does not depend on binden: for an error, its nearest built-in type, a known system or stream code and its status; for a request, its method and its path without the query; for a response, its status. A value of another kind is reduced to its type. The `error_details` option adds the message, the stack and any code of an error. Custom `serializers` now replace the default ones of their keys only. `Logger` gets `level` (readable and writable), `isLevelEnabled()`, `flush()`, `child(bindings, { level })` and `Logger.fromPino()`, and `logger` with `destination` is a `TypeError`. The default instance creates its pino logger on first use, so `BINDEN_LOG_LEVEL` is read then. BREAKING CHANGE: errors are logged without their message and stack unless `error_details` is `true`, requests as `{ method, path }` and responses as `{ status }`; a `serializers` option no longer removes the default serializers of the other keys; `logger` and `destination` cannot be set together. Co-Authored-By: Claude Opus 5.5 --- packages/logger/README.md | 122 +++++++---- packages/logger/index.ts | 283 +++++++++++++++++++++++--- packages/logger/test/fixtures/lazy.ts | 6 + packages/logger/test/logger.test.ts | 264 +++++++++++++++++++++++- 4 files changed, 606 insertions(+), 69 deletions(-) create mode 100644 packages/logger/test/fixtures/lazy.ts diff --git a/packages/logger/README.md b/packages/logger/README.md index 47e5403c..575cc3d6 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -1,12 +1,6 @@ # @binden/logger [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![version](https://img.shields.io/npm/v/@binden/logger?style=plastic)](https://www.npmjs.com/package/@binden/logger) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/binden-js/binden/blob/main/CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![node version](https://img.shields.io/node/v/@binden/logger) ![npm downloads](https://img.shields.io/npm/dt/@binden/logger) [![License](https://img.shields.io/npm/l/@binden/logger)](https://github.com/binden-js/binden/blob/main/packages/logger/LICENSE) -A simple logger (based on [`pino`](https://github.com/pinojs/pino)). - -By default, the logging level is equal to the value of the `BINDEN_LOG_LEVEL` environmental variable. - -```bash -BINDEN_LOG_LEVEL=trace -``` +A simple logger (based on [`pino`](https://github.com/pinojs/pino)), with default serializers that keep the input of the requests out of the logs. ## Installation @@ -16,61 +10,111 @@ npm install @binden/logger ## Usage -```javascript +```typescript import { Logger } from "@binden/logger"; -const logger = new Logger({ level: Logger.getLevel("OTHER_ENV_NAME") }); + +const logger = new Logger({ level: "info" }); +logger.info("The server is listening", { port: 8080 }); ``` -or using the default import +or the default logger, whose level is the value of the `BINDEN_LOG_LEVEL` environment variable (`trace`, `debug`, `info`, `warn`, `error` or `fatal`; `silent` without it). It is created when it is first used: importing the package has no side effect, and the variable is read then. -```javascript +```bash +BINDEN_LOG_LEVEL=debug node server.js +``` + +```typescript import log from "@binden/logger"; + +log.debug("Received a message", { size: 42 }); +``` + +### Options + +`new Logger(options)` takes the options of [`pino`](https://getpino.io/#/docs/api?id=options) (e.g. `level`, `redact`, `serializers`), and: + +| Option | Default | Description | +| --------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `level` | `Logger.getLevel()` (`BINDEN_LOG_LEVEL`) | The level of the logger | +| `destination` | `stdout` | Where the records are written (a `pino` destination, or any object with a `write()` method) | +| `logger` | none | An existing `pino` logger to write with, as it is (its own options and serializers apply). It cannot be set with `destination` (a `TypeError`) | +| `error_details` | `false` | Whether the default `error` serializer adds the message, the stack and any `code` of an error | +| `serializers` | the default ones | Serializers by key: they replace the default ones of the same keys only | + +`Logger.getLevel(name)` reads a level from another environment variable: + +```typescript +import { Logger } from "@binden/logger"; + +const logger = new Logger({ level: Logger.getLevel("OTHER_ENV_NAME") }); ``` -- `.fatal()` +### Serializers -```javascript -log.fatal("Bad error. Exiting...", { id: 1, someinfo: { a: 2 } }); +The default serializers log an allowlist of details, found by duck typing (the logger does not depend on `binden`). A value of another kind under these keys is reduced to its type (e.g. `{ "type": "object" }`): it is never logged whole. + +- `error`: the type of the error (its nearest built-in error class, never a name the error defines), its `code` when it is a system error code (such as `ECONNRESET`) or one of a few stream and HTTP codes (such as `ERR_STREAM_PREMATURE_CLOSE`), and its `status` (an integer from 100 to 599). Its message, stack and any other `code` may quote the input: they are added only with `error_details: true`. It never throws, whatever is thrown; +- `request`: its method and its path (without the query string and the fragment, nor the scheme, the authority and the userinfo of an absolute-form target); +- `response`: its status (`statusCode`). + +```typescript +import { Logger, serializers } from "@binden/logger"; + +// The details of the errors, and the bodies, in development only +const logger = new Logger({ + level: "debug", + error_details: process.env.NODE_ENV === "development", + serializers: { body: (body: unknown): unknown => body }, +}); +logger.debug("The default serializers", { + serializers: Object.keys(serializers), +}); ``` -- `.error()` +### Methods + +- `.fatal()`, `.error()`, `.warn()`, `.info()`, `.debug()`, `.trace()` - write a record with a message and optional fields + +```typescript +import log from "@binden/logger"; -```javascript try { - doSomething(); + JSON.parse("{"); } catch (error) { - log.error("Something bad happened in the `doSomething`", { error }); + log.error("Something bad happened", { error }); } ``` -- `.warn()` +- `.child(bindings, { level })` - a logger that adds `bindings` to every record, with its own level if given -```javascript -if (connection) { - workWithConnection(); -} else { - log.warn("The connection has not been established", { reason }); -} +```typescript +import log from "@binden/logger"; + +const child = log.child({ module: "cache" }, { level: "debug" }); +child.debug("Cache miss", { key: "user:1" }); ``` -- `.info()` +- `.level` - the level of the logger (it can be changed), and `.isLevelEnabled(level)` - whether the records of a level are written -```javascript -server.on("listening", (port) => { - log.info(`The server is listening`, { port }); -}); +```typescript +import log from "@binden/logger"; + +if (log.isLevelEnabled("debug")) { + log.debug("An expensive record", { + details: JSON.stringify(process.memoryUsage()), + }); +} +log.level = "warn"; ``` -- `.debug()` +- `.flush()` - write the buffered records (e.g. of an asynchronous destination) before the process exits -```javascript -websocket.on("message", (message) => { - log.debug("Received a message", { message }); -}); +```typescript +import log from "@binden/logger"; + +await log.flush(); ``` -- `.trace()` +- `Logger.fromPino(logger)` - a logger that writes with an existing `pino` logger (e.g. `Logger.fromPino(pino({ level: "info" }))`), whose own options and serializers apply -```javascript -log.trace("Array length", { length: arr.length }); -``` +`ILogger` is the interface that `binden` needs (the six methods and `child()`): any other logger that has them can be used. diff --git a/packages/logger/index.ts b/packages/logger/index.ts index 1e805df2..a9361a9c 100644 --- a/packages/logger/index.ts +++ b/packages/logger/index.ts @@ -1,22 +1,30 @@ +import { constants } from "node:os"; import pino, { - stdSerializers, type Bindings, - type Logger as PinoLogger, - type LoggerOptions as PinoLoggerOptions, type DestinationStream, type LevelWithSilent, + type Logger as PinoLogger, + type LoggerOptions as PinoLoggerOptions, } from "pino"; +/** The options of a `Logger` */ export interface LoggerOptions extends PinoLoggerOptions { - destination?: DestinationStream; - logger?: PinoLogger; + /** Where the records are written (`stdout` by default) */ + destination?: DestinationStream | undefined; + /** An existing `pino` logger to use as it is (see `Logger.fromPino()`) */ + logger?: PinoLogger | undefined; + /** + * Whether the default `error` serializer adds the message, the stack and + * any `code` of an error (`false`): they may quote the input + */ + error_details?: boolean | undefined; } -const serializers = { - error: stdSerializers.err, - request: stdSerializers.req, - response: stdSerializers.res, -}; +/** The options of `Logger.child()` */ +export interface IChildOptions { + /** The level of the child (the level of its parent by default) */ + level?: LevelWithSilent | undefined; +} /** A logger that binden can use: the `Logger` of this package, or any object with these methods */ export interface ILogger { @@ -29,41 +37,261 @@ export interface ILogger { child(bindings: Record): ILogger; } +/** The built-in error classes, the nearest of which is the type of an error */ +const errorTypes: ReadonlyMap = new Map([ + [AggregateError.prototype, "AggregateError"], + [EvalError.prototype, "EvalError"], + [RangeError.prototype, "RangeError"], + [ReferenceError.prototype, "ReferenceError"], + [SyntaxError.prototype, "SyntaxError"], + [TypeError.prototype, "TypeError"], + [URIError.prototype, "URIError"], + [Error.prototype, "Error"], +]); + +/** The codes of an error that are logged: system errors and some stream/HTTP ones */ +const errorCodes: ReadonlySet = new Set([ + ...Object.keys(constants.errno), + "ERR_HTTP_CONTENT_LENGTH_MISMATCH", + "ERR_HTTP_HEADERS_SENT", + "ERR_HTTP_INVALID_HEADER_VALUE", + "ERR_INVALID_ARG_TYPE", + "ERR_INVALID_CHAR", + "ERR_STREAM_ALREADY_FINISHED", + "ERR_STREAM_DESTROYED", + "ERR_STREAM_PREMATURE_CLOSE", + "ERR_STREAM_WRITE_AFTER_END", +]); + +/** A property of any value: `null` instead of an error (e.g. a throwing getter) */ +function property(value: unknown, key: string): unknown { + try { + return Reflect.get(value as object, key) as unknown; + } catch { + return null; + } +} + +/** The type of a value: for an object, its nearest built-in error class */ +function typeOf(value: unknown): string { + if (value === null) { + return "null"; + } else if (typeof value !== "object" && typeof value !== "function") { + return typeof value; + } + try { + // Bounded: a proxy may return itself, or another proxy every time + const seen = new Set(); + let prototype = Object.getPrototypeOf(value) as object | null; + while (prototype !== null && !seen.has(prototype) && seen.size < 64) { + seen.add(prototype); + const type = errorTypes.get(prototype); + if (typeof type === "string") { + return type; + } + prototype = Object.getPrototypeOf(prototype) as object | null; + } + } catch { + // A proxy that refuses to tell its prototype + } + return typeof value; +} + +/** Whether a value is a status code: an integer from 100 to 599 */ +function isStatus(value: unknown): value is number { + return ( + Number.isInteger(value) && Number(value) >= 100 && Number(value) <= 599 + ); +} + +/** + * The path of a request-target: never its query or fragment, nor the scheme, + * the authority or the userinfo of an absolute-form target + */ +function pathOf(target: string): string { + if (target.startsWith("/")) { + return target.replace(/[?#].*$/su, ""); + } + return URL.canParse(target) ? new URL(target).pathname : ""; +} + +/** The default serializers of the `error`, `request` and `response` keys */ +export interface ISerializers { + error(error: unknown): Record; + request(request: unknown): Record; + response(response: unknown): Record; +} + +/** + * The default serializers: an allowlist of details, found by duck typing, so + * that the input of the requests (credentials, query strings, bodies, error + * messages that quote them) stays out of the logs. A value of another kind is + * reduced to its type. + */ +export function createSerializers({ + error_details = false, +}: { error_details?: boolean | undefined } = {}): ISerializers { + return { + // The type of an error, a known `code` and its `status` (and, with + // `error_details`, its message, its stack and any `code`) + error: (error: unknown): Record => { + const type = typeOf(error); + if (typeof error !== "object" && typeof error !== "function") { + return { type }; + } + const code = property(error, "code"); + const status = property(error, "status"); + const message = property(error, "message"); + const stack = property(error, "stack"); + const known = typeof code === "string" && errorCodes.has(code); + return { + type, + ...((known || error_details) && typeof code === "string" + ? { code } + : {}), + ...(isStatus(status) ? { status } : {}), + ...(error_details && typeof message === "string" ? { message } : {}), + ...(error_details && typeof stack === "string" ? { stack } : {}), + }; + }, + // The method and the path (without the query string) of a request + request: (request: unknown): Record => { + const method = property(request, "method"); + const url = property(request, "url"); + if (typeof method !== "string" && typeof url !== "string") { + return { type: typeOf(request) }; + } + return { + ...(typeof method === "string" ? { method } : {}), + ...(typeof url === "string" ? { path: pathOf(url) } : {}), + }; + }, + // The status of a response + response: (response: unknown): Record => { + const status = property(response, "statusCode"); + return isStatus(status) ? { status } : { type: typeOf(response) }; + }, + }; +} + +/** The default serializers (without the details of the errors) */ +export const serializers: ISerializers = createSerializers(); + +// eslint-disable-next-line init-declarations -- set by the static block of `Logger` +let createDefault: () => Logger; + +/** A logger based on `pino`, with safe default serializers */ export class Logger implements ILogger { - readonly #logger: PinoLogger; + static #deferred = false; + + #logger: PinoLogger | null; + readonly #create: () => PinoLogger; + + static { + // Its `pino` logger is created when it is first used: importing this + // package has no side effect, and `BINDEN_LOG_LEVEL` is read then + createDefault = (): Logger => { + Logger.#deferred = true; + try { + return new Logger(); + } finally { + Logger.#deferred = false; + } + }; + } + + public constructor({ + logger, + destination, + error_details = false, + serializers: custom = {}, + ...options + }: LoggerOptions = {}) { + if (typeof logger !== "undefined" && typeof destination !== "undefined") { + throw new TypeError("`logger` and `destination` cannot both be set"); + } else if (typeof error_details !== "boolean") { + throw new TypeError("`error_details` is not a boolean"); + } + this.#create = (): PinoLogger => { + if (typeof logger !== "undefined") { + return logger; + } + const merged: PinoLoggerOptions = { + level: Logger.getLevel(), + ...options, + serializers: { ...createSerializers({ error_details }), ...custom }, + }; + return typeof destination === "undefined" + ? pino(merged) + : pino(merged, destination); + }; + this.#logger = Logger.#deferred ? null : this.#create(); + } - public constructor({ logger, destination, ...options }: LoggerOptions = {}) { - this.#logger = destination - ? pino({ level: Logger.getLevel(), serializers, ...options }, destination) - : (logger ?? pino({ level: Logger.getLevel(), serializers, ...options })); + /** A logger that writes with an existing `pino` logger (its options apply) */ + public static fromPino(logger: PinoLogger): Logger { + return new Logger({ logger }); } - public child(params: Bindings = {}): Logger { - return new Logger({ logger: this.#logger.child(params) }); + /** The level of the logger */ + public get level(): LevelWithSilent { + return this.#pino().level as LevelWithSilent; + } + + public set level(level: LevelWithSilent) { + this.#pino().level = level; + } + + /** Whether the records of a level are written */ + public isLevelEnabled(level: string): boolean { + return this.#pino().isLevelEnabled(level); + } + + /** Writes the buffered records (e.g. before the process exits) */ + public flush(): Promise { + return new Promise((resolve, reject) => { + this.#pino().flush((error?: Error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } + + /** A logger that adds `bindings` to every record (with its own level, if given) */ + public child(bindings: Bindings = {}, { level }: IChildOptions = {}): Logger { + const logger = this.#pino(); + return Logger.fromPino( + typeof level === "undefined" + ? logger.child(bindings) + : logger.child(bindings, { level }), + ); } public fatal(message: string, extra: Record = {}): void { - this.#logger.fatal(extra, message); + this.#pino().fatal(extra, message); } public error(message: string, extra: Record = {}): void { - this.#logger.error(extra, message); + this.#pino().error(extra, message); } public warn(message: string, extra: Record = {}): void { - this.#logger.warn(extra, message); + this.#pino().warn(extra, message); } public info(message: string, extra: Record = {}): void { - this.#logger.info(extra, message); + this.#pino().info(extra, message); } public debug(message: string, extra: Record = {}): void { - this.#logger.debug(extra, message); + this.#pino().debug(extra, message); } public trace(message: string, extra: Record = {}): void { - this.#logger.trace(extra, message); + this.#pino().trace(extra, message); } public static get ENV_VARIABLE_NAME(): string { @@ -89,6 +317,13 @@ export class Logger implements ILogger { return "silent"; } } + + /** The `pino` logger, created on first use for the default logger */ + #pino(): PinoLogger { + this.#logger ??= this.#create(); + return this.#logger; + } } -export default new Logger(); +/** The default logger: its level is `BINDEN_LOG_LEVEL` (`silent` without it) */ +export default createDefault(); diff --git a/packages/logger/test/fixtures/lazy.ts b/packages/logger/test/fixtures/lazy.ts new file mode 100644 index 00000000..82c2fcaa --- /dev/null +++ b/packages/logger/test/fixtures/lazy.ts @@ -0,0 +1,6 @@ +// Run by `logger.test.ts`: the default logger is created when it is first used +// (its level is the one of the environment then), not when it is imported +import log from "../../index.js"; + +process.env.BINDEN_LOG_LEVEL = "warn"; +process.stdout.write(`${log.level}\n`); diff --git a/packages/logger/test/logger.test.ts b/packages/logger/test/logger.test.ts index 5e6ccf41..7b512d03 100644 --- a/packages/logger/test/logger.test.ts +++ b/packages/logger/test/logger.test.ts @@ -1,7 +1,9 @@ -import { deepEqual, equal, ok } from "node:assert/strict"; +import { deepEqual, equal, ok, rejects, throws } from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; import { describe, it } from "node:test"; import pino from "pino"; -import log, { Logger } from "../index.js"; +import log, { createSerializers, Logger, serializers } from "../index.js"; type Line = Record; @@ -141,16 +143,266 @@ describe("Logger", () => { ok(log instanceof Logger); }); - it("serializes errors", () => { + it("serializes an error by default without its details", () => { const sink = new Sink(); const logger = new Logger({ destination: sink, level: "error" }); - logger.error("failed", { error: new Error("boom") }); + logger.error("failed", { + error: Object.assign(new RangeError("secret"), { code: "ECONNRESET" }), + }); - const [line] = sink.lines; - const error = line.error as Line; + deepEqual(sink.lines[0].error, { type: "RangeError", code: "ECONNRESET" }); + }); + + it("serializes the details of an error with `error_details`", () => { + const sink = new Sink(); + const logger = new Logger({ + destination: sink, + level: "error", + error_details: true, + }); + + logger.error("failed", { + error: Object.assign(new Error("boom"), { code: "E_CUSTOM" }), + }); + + const error = sink.lines[0].error as Line; equal(error.type, "Error"); equal(error.message, "boom"); + equal(error.code, "E_CUSTOM"); equal(typeof error.stack, "string"); }); + + it("refuses invalid options", () => { + throws( + () => new Logger({ logger: pino(), destination: new Sink() }), + new TypeError("`logger` and `destination` cannot both be set"), + ); + throws( + () => new Logger({ error_details: 1 as unknown as boolean }), + new TypeError("`error_details` is not a boolean"), + ); + }); + + it("keeps the default serializers that are not replaced", () => { + const sink = new Sink(); + const logger = new Logger({ + destination: sink, + level: "info", + serializers: { body: (): string => "[Redacted]" }, + }); + + logger.info("request", { + body: "secret", + request: { method: "GET", url: "/x?token=secret" }, + error: new Error("secret"), + }); + + const [line] = sink.lines; + equal(line.body, "[Redacted]"); + deepEqual(line.request, { method: "GET", path: "/x" }); + deepEqual(line.error, { type: "Error" }); + }); + + it("`level` and `isLevelEnabled()`", () => { + const logger = new Logger({ destination: new Sink(), level: "info" }); + equal(logger.level, "info"); + ok(logger.isLevelEnabled("info")); + ok(!logger.isLevelEnabled("debug")); + + logger.level = "debug"; + equal(logger.level, "debug"); + ok(logger.isLevelEnabled("debug")); + }); + + it(".child() with its own level", () => { + const sink = new Sink(); + const logger = new Logger({ destination: sink, level: "info" }); + const child = logger.child({ id: 1 }, { level: "debug" }); + + equal(child.level, "debug"); + equal(logger.level, "info"); + child.debug("written"); + logger.debug("skipped"); + deepEqual( + sink.lines.map(({ msg, id }) => [msg, id]), + [["written", 1]], + ); + }); + + it("Logger.fromPino()", () => { + const sink = new Sink(); + const logger = Logger.fromPino(pino({ level: "warn" }, sink)); + ok(logger instanceof Logger); + logger.warn("wrapped"); + equal(sink.lines[0].msg, "wrapped"); + equal(logger.level, "warn"); + }); + + it(".flush()", async () => { + // A destination that buffers nothing + await new Logger({ destination: new Sink() }).flush(); + + let flushed = false; + const buffered = { + write: (): void => { + // Written later + }, + flush: (callback: (error?: Error) => void): void => { + flushed = true; + callback(); + }, + }; + await new Logger({ destination: buffered }).flush(); + ok(flushed); + + const failing = { + write: (): void => { + // Written later + }, + flush: (callback: (error?: Error) => void): void => { + callback(new Error("The destination has failed")); + }, + }; + await rejects( + new Logger({ destination: failing }).flush(), + new Error("The destination has failed"), + ); + }); + + it("the default logger is created on first use", () => { + const { status, stdout, stderr } = spawnSync( + process.execPath, + ["--import", "tsx", join(import.meta.dirname, "fixtures", "lazy.ts")], + { encoding: "utf8", timeout: 30_000 }, + ); + deepEqual( + { status, stdout, stderr }, + { status: 0, stdout: "warn\n", stderr: "" }, + ); + }); +}); + +describe("serializers", () => { + it("error: the type, a known code and the status only", () => { + class SECRET_CLASS extends TypeError {} + deepEqual(serializers.error(new SECRET_CLASS("secret")), { + type: "TypeError", + }); + deepEqual( + serializers.error( + Object.assign(new Error("secret"), { code: "ECONNRESET", status: 404 }), + ), + { type: "Error", code: "ECONNRESET", status: 404 }, + ); + deepEqual( + serializers.error( + Object.assign(new Error("secret"), { code: "SECRET", status: 999 }), + ), + { type: "Error" }, + ); + deepEqual(serializers.error({ status: 400, message: "secret", code: 1 }), { + type: "object", + status: 400, + }); + const errors = [ + new AggregateError([]), + new EvalError(), + new ReferenceError(), + new SyntaxError(), + new URIError(), + new RangeError(), + ]; + deepEqual( + errors.map((error) => serializers.error(error).type), + [ + "AggregateError", + "EvalError", + "ReferenceError", + "SyntaxError", + "URIError", + "RangeError", + ], + ); + deepEqual(serializers.error(null), { type: "null" }); + deepEqual(serializers.error("secret"), { type: "string" }); + deepEqual(serializers.error(Object.create(null) as object), { + type: "object", + }); + }); + + it("error: never throws, nor loops forever", () => { + const getters = new Error("secret"); + for (const key of ["code", "status", "message", "stack"]) { + Object.defineProperty(getters, key, { + get(): never { + throw new Error("getter"); + }, + }); + } + deepEqual(createSerializers({ error_details: true }).error(getters), { + type: "Error", + }); + + const thrower = (): never => { + throw new Error("trap"); + }; + const traps = new Proxy(new Error("secret"), { + get: thrower, + getPrototypeOf: thrower, + }); + deepEqual(serializers.error(traps), { type: "object" }); + + // Its prototype is itself, or a new proxy every time + const cyclic: object = new Proxy( + {}, + { + getPrototypeOf(): object { + return cyclic; + }, + }, + ); + const endless = (): object => + new Proxy( + {}, + { + getPrototypeOf(): object { + return endless(); + }, + }, + ); + const start = performance.now(); + deepEqual(serializers.error(cyclic), { type: "object" }); + deepEqual(serializers.error(endless()), { type: "object" }); + // A bounded walk, not one until the set of the seen prototypes is full + ok(performance.now() - start < 500); + deepEqual(serializers.error(thrower), { type: "function" }); + }); + + it("request: the method and the path without the query", () => { + deepEqual( + serializers.request({ + method: "POST", + url: "/x?token=secret#fragment", + headers: { authorization: "secret" }, + }), + { method: "POST", path: "/x" }, + ); + deepEqual(serializers.request({ url: "http://user:secret@host/x?t=1" }), { + path: "/x", + }); + deepEqual(serializers.request({ url: "*" }), { path: "" }); + deepEqual(serializers.request({ method: "GET" }), { method: "GET" }); + deepEqual(serializers.request({ secret: "secret" }), { type: "object" }); + deepEqual(serializers.request("secret"), { type: "string" }); + }); + + it("response: the status only", () => { + deepEqual( + serializers.response({ statusCode: 201, headers: { "set-cookie": "s" } }), + { status: 201 }, + ); + deepEqual(serializers.response({ statusCode: 1000 }), { type: "object" }); + deepEqual(serializers.response(Symbol("secret")), { type: "symbol" }); + }); }); From 091b47eb9c4958315c4b9caadb337a4ba390ae3d Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:50:39 +0300 Subject: [PATCH 143/675] feat!: log safe error snapshots and add error headers in binden The framework logged the errors it met as they were thrown, together with the context: what reached the logs depended on the serializers of the logger, and an error message or a cause may quote the input of a request. A logger that failed could also break the handling of a request, or leave an unhandled rejection. The framework now logs only snapshots that it builds itself, under the `failure` key: the type of the error (the nearest class of binden or of the built-in ones), a known system, stream or framework code, the status, the request id and the location (`middleware`, `error_handler`, `send`, `cleanup`, `shutdown`). The message, the stack, the cause and any other code are added with the new `log_error_details` option only. `context.log`, and every logger call of the framework, never fails: a throw is ignored and a returned rejection observed, also for the other packages that log through the context. The `logger` option must have the methods of `ILogger`, and the default logger is created on first use. The default error handler replies with the status of an error only when it is an integer from 400 to 599 (500 otherwise), sends the `headers` of a `BindenError` (copied, frozen and checked at its creation: no framing, representation or connection header, no CR or LF), logs a server error at `error` and a client error at `debug`, and a second reply through the context is logged as a warning. BREAKING CHANGE: the framework logs its errors as `failure` snapshots instead of `{ context, error }`; a thrown value whose `status` is not an integer from 400 to 599 (e.g. a string, or 302) gets 500; the `serializers` of binden log a request as `{ method, path }`, a response as `{ status }` and an error without `expose`; `logger` must have the methods of `ILogger`. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 67 +++- packages/binden/index.ts | 8 +- packages/binden/src/binden.ts | 243 ++++++------ packages/binden/src/context.ts | 38 +- packages/binden/src/error.ts | 71 ++++ packages/binden/src/logger.ts | 261 +++++++++---- packages/binden/test/binden.test.ts | 17 +- packages/binden/test/error.test.ts | 53 +++ packages/binden/test/errors.test.ts | 251 +++++++++++++ packages/binden/test/exchange.test.ts | 30 +- packages/binden/test/fixtures/async-logger.ts | 60 +++ packages/binden/test/logger.test.ts | 353 ++++++++++-------- packages/binden/test/process.test.ts | 8 + packages/binden/test/shutdown.test.ts | 23 +- 14 files changed, 1094 insertions(+), 389 deletions(-) create mode 100644 packages/binden/test/errors.test.ts create mode 100644 packages/binden/test/fixtures/async-logger.ts diff --git a/packages/binden/README.md b/packages/binden/README.md index 23783c09..83c9dda1 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -21,6 +21,7 @@ An application is a `Binden`: the root [stack](#stack-and-routing), whose option | `error_handler_timeout` | `5000` | The milliseconds that the `error_handler` has to settle (an integer from 1 to 2³¹−1) | | `timeout` | `0` (none) | The deadline of every request in milliseconds (an integer from 0 to 2³¹−1, see [Lifecycle](#lifecycle)) | | `logger` | silent unless `BINDEN_LOG_LEVEL` is set | See [Logging](#logging) | +| `log_error_details` | `false` | Whether the errors that the framework logs include their message, stack, cause and any `code` (they may quote the input of a request, see [Logging](#logging)) | | `routing` | `{ case_sensitive: true, strict: true }` | The matching of the patterns of `route()` and `mount()`: `case_sensitive: false` compares their literal segments without their case, and `strict: false` ignores one trailing `/` (see [Stack and routing](#stack-and-routing)) | | `state` | `() => ({})` | Creates `context.state` for every request (required by the types when the state has required fields) | | `trust_proxy`, `allowed_hosts`, `request_id`, `encoded_slashes`, `query` | see [Request options](#request-options) | The options of the requests | @@ -137,15 +138,33 @@ const removed = app.off("/path", middleware1); ### Logging -Every context logs through `context.log`: a child (with the `trace_id` of the context) of the logger of the application. The default logger (see [`@binden/logger`](https://github.com/binden-js/binden/tree/main/packages/logger)) is silent unless the `BINDEN_LOG_LEVEL` environment variable sets a level, and its `serializers` log an allowlist of details only, which keeps credentials and the input of the requests out of the logs: +Every context logs through `context.log`: a child (with the `trace_id` of the context) of the logger of the application. Its calls never fail: a logger that throws (or whose `child()` throws) is ignored, and a promise it returns is observed, so that logging never breaks the handling of a request (also in the other packages, which log through it). -- a request is its method, its path and its `Host`, `User-Agent`, `Content-Type` and `Content-Length` headers. The path is logged without the query string and the fragment, nor the scheme, authority and userinfo of an absolute-form request-target; anything secret in the path itself (e.g. a token) is logged; -- a response is its status code and its `Content-Type` and `Content-Length` headers; -- a context is its `id`, request and response (not the fields the application sets on it); -- a logged `body` (e.g. the one that [`@binden/body`](https://github.com/binden-js/binden/tree/main/packages/body) failed to parse) is redacted; -- an `error` is reduced to its type (the nearest of `BindenError` and the built-in error classes, never a name the error defines), its `status` and `expose`, and its `code` when it is a system error code (such as `ECONNRESET`) or one of a few stream and HTTP codes (such as `ERR_STREAM_PREMATURE_CLOSE`). Its message, stack, cause and `json` may quote the input (e.g. an error of `JSON.parse()`). The serializer never throws, whatever is thrown. +The framework logs only snapshots that it builds itself, under the `failure` key: the type of the error (the nearest of `BindenError`, `BindenAbortError` and the built-in error classes, never a name that the error defines), its `code` when it is a system error code (such as `ECONNRESET`), one of a few stream and HTTP codes (such as `ERR_STREAM_PREMATURE_CLOSE`) or a code of the framework (`deadline`, `aborted`), its `status`, the `request_id` and the `location` in the framework (`middleware`, `error_handler`, `send`, `cleanup` or `shutdown`). The message, the stack, the cause and any other `code` of an error may quote the input of a request (e.g. an error of `JSON.parse()`): they are added only with the `log_error_details: true` option. -A custom logger (for example with another level or destination, or to log more details) is passed with the `logger` option. The default `serializers` are exported to build on: +```json +{ + "level": 50, + "trace_id": "3f6c…", + "failure": { + "type": "TypeError", + "status": 500, + "location": "middleware", + "request_id": "3f6c…" + }, + "msg": "The request has failed" +} +``` + +The default logger (see [`@binden/logger`](https://github.com/binden-js/binden/tree/main/packages/logger)) is created when an application (or a context) first needs it. It is silent unless the `BINDEN_LOG_LEVEL` environment variable sets a level, and its `serializers` log an allowlist of details only, which keeps credentials and the input of the requests out of the logs: + +- a `request` is its method and its path (without the query string and the fragment, nor the scheme, authority and userinfo of an absolute-form request-target; anything secret in the path itself, e.g. a token, is logged); +- a `response` is its status code; +- a `context` is its `id`, request and response (not the fields the application sets on it); +- a `body` (e.g. the one that [`@binden/body`](https://github.com/binden-js/binden/tree/main/packages/body) failed to parse) is redacted; +- an `error` is reduced to its type, a known `code` and its `status`, as in the snapshots. + +A value of another kind under these keys is reduced to its type. A custom logger (for example with another level or destination, or to log more details) is passed with the `logger` option: any object with the methods of `ILogger` (`fatal`, `error`, `warn`, `info`, `debug`, `trace` and `child`). The default `serializers` are exported to build on: ```typescript import { Binden, serializers } from "binden"; @@ -154,16 +173,15 @@ import { Logger } from "@binden/logger"; const logger = new Logger({ level: "debug", serializers }); const app = new Binden({ logger }); -// Log the request bodies as well +// Log the request bodies, and the details of the errors, as well const verbose = new Logger({ level: "debug", + error_details: true, serializers: { ...serializers, body: (body: unknown): unknown => body }, }); -const debugApp = new Binden({ logger: verbose }); +const debugApp = new Binden({ logger: verbose, log_error_details: true }); ``` -Likewise, `error` can be replaced with a serializer that keeps the details of the errors (e.g. `stdSerializers.err` of [pino](https://getpino.io/#/docs/api?id=pino-stdserializers)). - ### Lifecycle Every request has an abort signal, `context.signal`, aborted at most once (its `reason` is a `BindenAbortError`, whose `reason` says why): @@ -227,7 +245,7 @@ process.once("SIGTERM", () => { ### Context -- `.log` - get the logger (see [`@binden/logger`](https://github.com/binden-js/binden/tree/main/packages/logger)) +- `.log` - get the logger: a child of the logger of the application, with the `trace_id` of the context, whose calls never fail (see [Logging](#logging)) ```typescript const { log } = context; @@ -298,7 +316,7 @@ const { log.trace("url search string", { search }); ``` -- `.send()` - execute `context.response.send()` and set `context.done` to `true` +- `.send()` - execute `context.response.send()` and set `context.done` to `true`. Like `.json()`, `.text()`, `.html()`, `.form()` and `.sendFile()`, it logs a warning when the response has already been started (e.g. a second reply, which is not sent), and does nothing once the deadline has passed (see [Lifecycle](#lifecycle)) ```typescript const data = "Hello World!"; @@ -503,7 +521,11 @@ const app = new Binden().use("/items", list, create); ### Errors -A middleware that throws (or rejects) passes the error to the error handler. The default one replies with the status of a `BindenError` (see below), or `500 Internal Server Error` for any other value; a reply that has already started is cut by closing the connection instead. +A middleware that throws (or rejects) passes the error to the error handler. The default one: + +- replies with the `status` of the error when it is an integer from 400 to 599 (e.g. of a [`BindenError`](#bindenerror)), and `500 Internal Server Error` otherwise (for any thrown value, also `null`), with the `headers` of a `BindenError` and its exposed `json` or `message`. The framing and representation headers set for the body that is not sent (e.g. `Content-Length`, `Content-Encoding` or `ETag`) are removed, and the cookies of the response are sent; +- logs a server error (5xx) at `error`, and a client error (4xx) at `debug` (see [Logging](#logging)); +- cuts a reply that has already started by closing the connection, and leaves a reply that has ended. The `error_handler` option replaces it. It gets the context and the thrown value (which may be anything, also `null`), may be `async` (it is awaited), and must have ended its reply when it settles: nothing started then gets the default reply, while a reply that is not ended (e.g. a `write()` without `end()`) is cut by closing the connection. A handler that throws, rejects or does not settle within `error_handler_timeout` gets an empty `500 Internal Server Error` instead (without the framing and representation headers it may have set), or the connection is closed when its reply has started; its later reply is ignored, and its later failure is logged (`debug`). @@ -520,7 +542,22 @@ const app = new Binden({ ### BindenError -`BindenError` represents an HTTP error, with a status code from 400 to 599 (an integer: anything else throws a `TypeError`) +`BindenError` represents an HTTP error, with a status code from 400 to 599 (an integer: anything else throws a `TypeError`, and one out of this range a `RangeError`). Its options: + +- `expose` (default `false`) - whether the default error handler sends `json` (as `application/json`) or `message` (as `text/plain`); otherwise the reply is empty; +- `json`, `message` (default: the reason phrase of the status) and `cause`; +- `code` - a machine-readable reason (such as `deadline`), which is logged but not sent; +- `headers` - headers that the default error handler sends with the error (e.g. `Allow`, `Retry-After` or `WWW-Authenticate`). They are copied (and frozen) when the error is created, and checked: an invalid name or value (e.g. with `CR` or `LF`), a duplicate name, or a header of the framing, the representation or the connection (`Content-Length`, `Transfer-Encoding`, `Content-Encoding`, `Content-Range`, `Content-Type` or `Connection`) throws a `TypeError`. + +```typescript +import { BindenError } from "binden"; + +throw new BindenError(429, { + expose: true, + json: { error: "Too many requests" }, + headers: { "Retry-After": "60" }, +}); +``` ```typescript import { Middleware, Context, BindenError } from "binden"; diff --git a/packages/binden/index.ts b/packages/binden/index.ts index 137324f4..16532d55 100644 --- a/packages/binden/index.ts +++ b/packages/binden/index.ts @@ -12,7 +12,11 @@ export * from "./src/headers/if-modified-since.js"; export * from "./src/headers/range.js"; export * from "./src/context.js"; export * from "./src/error.js"; -export { BindenAbortError, type IAbortReason } from "./src/exchange.js"; +export { + BindenAbortError, + type IAbortReason, + type IExchangeReport, +} from "./src/exchange.js"; export * from "./src/binden.js"; export * from "./src/middleware.js"; export * from "./src/request.js"; @@ -34,4 +38,4 @@ export { type IStackPath, type IUntypedItem, } from "./src/stack.js"; -export { serializers } from "./src/logger.js"; +export { type ILocation, serializers } from "./src/logger.js"; diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index 64f81c99..57005e9f 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -5,7 +5,13 @@ import { Context } from "./context.js"; import { BindenError } from "./error.js"; import { validateDelay } from "./delay.js"; import { BindenAbortError, Exchange } from "./exchange.js"; -import defaultLogger from "./logger.js"; +import { + defaultLogger, + type ILocation, + isLogger, + safe, + snapshot, +} from "./logger.js"; import { Middleware, type IMiddleware } from "./middleware.js"; import { property } from "./property.js"; import { BindenRequest, type ITrustProxy } from "./request.js"; @@ -14,12 +20,9 @@ import { matchPrefix, matchRoute, type IRoutingOptions } from "./route.js"; import { Router } from "./router.js"; import { isClosing, register, track } from "./shutdown.js"; import { type ILayer, layersOf, markRoot, Stack } from "./stack.js"; -import { isStatus } from "./status.js"; export const DefaultErrorCode = 500; -type ILogLevel = "debug" | "error" | "trace" | "warn"; - /** Replaces the default reply to an error (see `IBindenOptions.error_handler`) */ export type IErrorHandler> = ( context: Context, @@ -54,6 +57,11 @@ export interface IBindenBaseOptions< timeout?: number | undefined; /** The logger of the contexts (the default one is set up by `BINDEN_LOG_LEVEL`) */ logger?: ILogger | undefined; + /** + * Whether the errors that the framework logs include their message, stack, + * cause and any `code` (`false`): they may quote the input of a request + */ + log_error_details?: boolean | undefined; /** The matching of the patterns of `route()` and `mount()` */ routing?: IRoutingOptions | undefined; /** @@ -111,6 +119,7 @@ export class Binden< readonly #error_handler_timeout: number; readonly #timeout: number; readonly #logger: ILogger; + readonly #details: boolean; readonly #routing: Required; readonly #state: (() => object) | null; readonly #Request: typeof BindenRequest; @@ -128,7 +137,8 @@ export class Binden< error_handler, error_handler_timeout = 5000, auto_head = true, - logger = defaultLogger, + logger, + log_error_details = false, routing = {}, state, timeout = 0, @@ -143,6 +153,10 @@ export class Binden< validateDelay("timeout", timeout, 0); validateDelay("error_handler_timeout", error_handler_timeout, 1); Binden.#boolean("auto_head", auto_head); + Binden.#boolean("log_error_details", log_error_details); + if (typeof logger !== "undefined" && !isLogger(logger)) { + throw new TypeError("`logger` is not a logger"); + } if ( typeof error_handler !== "undefined" && typeof error_handler !== "function" @@ -162,7 +176,8 @@ export class Binden< this.#error_handler_timeout = error_handler_timeout; this.#timeout = timeout; this.#auto_head = auto_head; - this.#logger = logger; + this.#logger = safe(logger ?? defaultLogger()); + this.#details = log_error_details; this.#routing = { case_sensitive, strict }; this.#state = state ?? null; // The requests of this application trust its proxies @@ -224,7 +239,12 @@ export class Binden< server: http.Server | https.Server, ): (request: http.IncomingMessage, response: http.ServerResponse) => void { register(server, (message, error) => { - this.#logRoot("error", message, { error }); + this.#logger.error(message, { + failure: snapshot(error, { + location: "shutdown", + details: this.#details, + }), + }); }); return (request, response): void => { if ( @@ -233,7 +253,7 @@ export class Binden< ) { const message = "The request and the response are not those of the application (`serverOptions()`)"; - this.#logRoot("error", message, {}); + this.#logger.error(message); request.socket.destroy(); return; } @@ -251,20 +271,29 @@ export class Binden< response: BindenResponse, ): void { const { state, failure } = this.#createState(); - const exchange = Exchange.for(request, response, { state }); + const context = new Context({ + request, + response, + state, + logger: this.#logger, + report: (message, error): void => { + context.log.error(message, this.#failure(context, error, "cleanup")); + }, + }); + const exchange = Exchange.for(request, response); track(server, exchange, response); - const context = new Context({ request, response, logger: this.#logger }); if (this.#timeout > 0) { exchange.setTimeout(this.#timeout); } // A request on a connection that was open once the server is closing // gets 503: the stack does not run const handled = isClosing(server) - ? Binden.#closing(context) + ? this.#closing(context) : this.#run(context, exchange, failure); handled.catch((error: unknown) => { // Unexpected (every failure is handled): the process must not crash - Binden.#log(context, "error", "The request has failed", { error }); + const message = "The request has failed"; + context.log.error(message, this.#failure(context, error, "send")); response.destroy(); }); } @@ -314,7 +343,7 @@ export class Binden< !context.response.writableFinished ) { const message = "The deadline has passed. Destroying the socket"; - Binden.#log(context, "debug", message, {}); + context.log.debug(message); context.response.destroy(); } }, @@ -349,8 +378,7 @@ export class Binden< await response.send(); return; } - response.setHeader("Allow", methods); - throw new BindenError(405); + throw new BindenError(405, { headers: { Allow: methods } }); } throw new BindenError(404); } @@ -409,7 +437,7 @@ export class Binden< const middlewares = item instanceof Router ? this.#methodOf(item, run, allow) : [item]; for (const middleware of middlewares) { - context = await Binden.#runMiddleware(middleware, context, exchange); + context = await this.#runMiddleware(middleware, context, exchange); run.context = context; if (context.done) { await Binden.#complete(context); @@ -493,7 +521,7 @@ export class Binden< } if (!response.writableFinished) { const message = "The request has been aborted. Destroying the socket"; - Binden.#log(context, "debug", message, { reason: reason.reason }); + context.log.debug(message, { reason: reason.reason }); response.destroy(); } } @@ -501,12 +529,28 @@ export class Binden< /** A middleware has set `done`: a response that nothing has started is sent empty */ static async #complete(context: Context): Promise { if (!context.response.started) { - const extra = { context }; - Binden.#log(context, "warn", "`done` without a response", extra); + context.log.warn("`done` without a response"); await context.response.send(); } } + /** What the framework logs of an error (see `log_error_details`) */ + #failure( + context: Context, + error: unknown, + location: ILocation, + status?: number, + ): { failure: Record } { + return { + failure: snapshot(error, { + location, + request_id: context.id, + details: this.#details, + status, + }), + }; + } + /** * Error handler: the provided `error_handler` (awaited within * `error_handler_timeout`), then the default reply if needed @@ -517,9 +561,8 @@ export class Binden< error: unknown, ): Promise { if (this.#error_handler) { - const base = { context, error }; const message = "Passing error to the provided `error_handler`"; - Binden.#log(context, "trace", message, base); + context.log.trace(message, this.#failure(context, error, "middleware")); const outcome = await this.#bounded(this.#error_handler, context, error); @@ -528,17 +571,21 @@ export class Binden< // The handler has failed, or has not settled in time: it may no // longer reply, and the request gets an opaque error exchange.seal(); - const failed = - outcome === EXPIRED - ? "The provided `error_handler` has not settled in time" - : "The provided `error_handler` has failed"; - const extra = - outcome === EXPIRED ? { context } : { context, error: outcome.error }; - Binden.#log(context, "error", failed, extra); + if (outcome === EXPIRED) { + const late = "The provided `error_handler` has not settled in time"; + context.log.error( + late, + this.#failure(context, error, "error_handler"), + ); + } else { + const failed = "The provided `error_handler` has failed"; + const extra = this.#failure(context, outcome.error, "error_handler"); + context.log.error(failed, extra); + } if (response.started) { response.destroy(); } else { - await exchange.reply(async () => Binden.#opaque(context)); + await exchange.reply(async () => this.#opaque(context)); } return; } else if (response.writableEnded) { @@ -547,20 +594,21 @@ export class Binden< // A reply that is not ended once the handler has settled (e.g. a // `write()` without `end()`) would leave the client waiting const unfinished = - "The provided `error_handler` has not ended its reply"; - Binden.#log( - context, - "warn", - `${unfinished}. Destroying the socket`, - base, + "The provided `error_handler` has not ended its reply. Destroying the socket"; + context.log.warn( + unfinished, + this.#failure(context, error, "error_handler"), ); response.destroy(); return; } const ignored = "The provided `error_handler` has not replied"; - Binden.#log(context, "debug", ignored, base); + context.log.debug( + ignored, + this.#failure(context, error, "error_handler"), + ); } - await Binden.#defaultErrorHandler(context, error); + await this.#defaultErrorHandler(context, error); } /** @@ -581,7 +629,10 @@ export class Binden< (failure: unknown) => { if (expired) { const late = "The provided `error_handler` has failed late"; - Binden.#log(context, "debug", late, { context, error: failure }); + context.log.debug( + late, + this.#failure(context, failure, "error_handler"), + ); } return { error: failure }; }, @@ -600,67 +651,71 @@ export class Binden< * An empty `500 Internal Server Error`, without the headers of the body that * a failed handler may have set */ - static async #opaque(context: Context): Promise { + async #opaque(context: Context): Promise { const { response } = context; for (const name of [...Binden.#representation, "Content-Type"]) { response.removeHeader(name); } - await Binden.#empty(context, DefaultErrorCode); + await this.#empty(context, DefaultErrorCode); } /** `503 Service Unavailable` to a request that arrives once closing */ - static async #closing(context: Context): Promise { + async #closing(context: Context): Promise { context.response.setHeader("Connection", "close"); - await Binden.#empty(context, 503); + await this.#empty(context, 503); } /** An empty reply of the framework (a failure to send it is logged) */ - static async #empty(context: Context, status: number): Promise { + async #empty(context: Context, status: number): Promise { context.response.status(status); try { await context.response.send(); - } catch { - Binden.#log(context, "debug", "The error reply has not been sent", { - context, - }); + } catch (error: unknown) { + const message = "The error reply has not been sent"; + context.log.debug(message, this.#failure(context, error, "send")); } } /** * Default reply to an error, which may be any thrown value: its properties - * are read without trusting them (e.g. `throw null`, or a throwing getter) + * are read without trusting them (e.g. `throw null`, or a throwing getter). + * Its status is the `status` of the error (an integer from 400 to 599, or + * `500`), with the `headers` of a `BindenError`; a server error (5xx) is + * logged at `error`, a client one at `debug`. */ - static async #defaultErrorHandler( - context: Context, - error: unknown, - ): Promise { - const { response, request } = context; - // Only the context and the error, whose serializers keep the input out of the logs - const base = { context, error }; - const log = (level: ILogLevel, message: string): void => { - Binden.#log(context, level, message, base); - }; + async #defaultErrorHandler(context: Context, error: unknown): Promise { + const { response } = context; + const raw = property(error, "status"); + const status = + Number.isInteger(raw) && Number(raw) >= 400 && Number(raw) <= 599 + ? Number(raw) + : DefaultErrorCode; + const extra = this.#failure(context, error, "middleware", status); + const level = status >= 500 ? "error" : "debug"; if (response.writableEnded) { // Sent (or being sent): destroying the socket would cut it - log("debug", "`.end()` has been called. Leaving the response"); + const ended = "The request has failed once its response was ended"; + context.log[level](ended, extra); return; } else if (response.started) { // Part of the body may be sent (or compressed): ending the response would // make a truncated body look complete - log("debug", "The response has been started. Destroying the socket"); - request.destroy(); + const started = + "The request has failed once its response was started. Destroying the socket"; + context.log[level](started, extra); + response.destroy(); return; } + context.log[level]("The request has failed", extra); // The framing and representation of the body that is not sent for (const name of Binden.#representation) { response.removeHeader(name); } - - const raw = property(error, "status"); - const errorStatus = - typeof raw === "number" || typeof raw === "string" ? Number(raw) : NaN; - response.status(isStatus(errorStatus) ? errorStatus : DefaultErrorCode); + response.status(status); + if (error instanceof BindenError) { + response.set(error.headers); + } // Sent like any other response: with its cookies, framed by its status const reply = (): Promise => { @@ -668,18 +723,17 @@ export class Binden< const json = property(error, "json"); const message = property(error, "message"); if (!expose) { - log("debug", "Error should not be exposed"); return response.send(); } else if (typeof json === "object" && json !== null) { try { const body = JSON.stringify(json); return response.setHeader("Content-Type", ct_json).send(body); } catch { - log("warn", "`JSON.stringify` failed"); + context.log.warn("`JSON.stringify` failed", extra); return response.send(); } } else if (typeof message !== "string" || !message) { - log("debug", "`message` is not a valid string"); + context.log.debug("`message` is not a valid string", extra); return response.send(); } return response.send(message); @@ -687,13 +741,14 @@ export class Binden< try { await reply(); - } catch { + } catch (failure: unknown) { // E.g. the client went away - log("debug", "The error reply has not been sent"); + const message = "The error reply has not been sent"; + context.log.debug(message, this.#failure(context, failure, "send")); } } - static async #runMiddleware( + async #runMiddleware( middleware: IMiddleware, context: Context, exchange: Exchange, @@ -701,7 +756,7 @@ export class Binden< const { name } = middleware.constructor; if (middleware instanceof Middleware && middleware.disabled) { - Binden.#log(context, "debug", "Middleware is disabled", { name }); + context.log.debug("Middleware is disabled", { middleware: name }); return context; } @@ -712,7 +767,7 @@ export class Binden< : middleware.run(context); const next = typeof property(result, "then") === "function" - ? await Binden.#race(result, context, exchange) + ? await this.#race(result, context, exchange) : result; if (!(next instanceof Context)) { return context; @@ -731,8 +786,10 @@ export class Binden< if (error === ABORTED) { throw error; } else if (middleware instanceof Middleware && middleware.ignore_errors) { - const extra = { error, name }; - Binden.#log(context, "debug", "Middleware ignores errors", extra); + context.log.debug("Middleware ignores errors", { + middleware: name, + ...this.#failure(context, error, "middleware"), + }); return context; } @@ -744,7 +801,7 @@ export class Binden< * The result of a middleware, unless the request is aborted first: its late * result is then ignored (and a late failure logged) */ - static async #race( + async #race( result: unknown, context: Context, exchange: Exchange, @@ -755,7 +812,10 @@ export class Binden< if (exchange.signal.aborted) { const message = "A middleware has failed after the request was aborted"; - Binden.#log(context, "debug", message, { context, error }); + context.log.debug( + message, + this.#failure(context, error, "middleware"), + ); } return { ok: false, error } as const; }, @@ -783,33 +843,6 @@ export class Binden< "Transfer-Encoding", ]; - /** Logs with the logger of the application (never throws) */ - #logRoot( - level: ILogLevel, - message: string, - extra: Record, - ): void { - try { - this.#logger[level](message, extra); - } catch { - // Nothing else to report it to - } - } - - /** Framework logs never break the handling of a request (e.g. a failing serializer) */ - static #log( - context: Context, - level: ILogLevel, - message: string, - extra: Record, - ): void { - try { - context.log[level](message, extra); - } catch { - // Nothing else to report it to - } - } - /** * The state of a request, from the `state` option: a factory that fails (or * that does not return an object) fails the request diff --git a/packages/binden/src/context.ts b/packages/binden/src/context.ts index aafb01fa..b2bac752 100644 --- a/packages/binden/src/context.ts +++ b/packages/binden/src/context.ts @@ -1,7 +1,7 @@ import type { ILogger } from "@binden/logger"; import { BindenError, type IBindenErrorOptions } from "./error.js"; -import { Exchange } from "./exchange.js"; -import defaultLogger from "./logger.js"; +import { Exchange, type IExchangeReport } from "./exchange.js"; +import { defaultLogger, safe, snapshot } from "./logger.js"; import type { Readable } from "node:stream"; import type { BindenRequest } from "./request.js"; @@ -20,6 +20,11 @@ export interface IContextOptions extends IBaseContext { * (the others share it) */ readonly state?: object | undefined; + /** + * Reports a failure of a cleanup of the request (used by the first context + * of the request only): logged by default (`error`) + */ + readonly report?: IExchangeReport | undefined; } export type IParams = Record; @@ -37,32 +42,32 @@ export class Context< public constructor({ request, response, - logger = defaultLogger, + logger, state, + report, }: IContextOptions) { this.#request = request; this.#response = response; - this.#parent = logger; + this.#parent = safe(logger ?? defaultLogger()); this.#exchange = Exchange.for(request, response, { state, - report: (message, error) => this.log.error(message, { error }), + report: + report ?? + ((message, error): void => { + const options = { location: "cleanup", details: false } as const; + this.log.error(message, { failure: snapshot(error, options) }); + }), }); } /** * A child of the logger with the `trace_id` of the context, created when it - * is first used. A logger that fails to create one (e.g. a custom logger - * whose `child()` throws) is used itself: logging never prevents the context - * from being created. + * is first used. Its calls never fail (e.g. a custom logger that throws, or + * whose `child()` throws, is ignored, and its rejected promises observed): + * logging never prevents the request from being handled. */ public get log(): ILogger { - if (!this.#logger) { - try { - this.#logger = this.#parent.child({ trace_id: this.id }); - } catch { - this.#logger = this.#parent; - } - } + this.#logger ??= this.#parent.child({ trace_id: this.id }); return this.#logger; } @@ -188,6 +193,9 @@ export class Context< if (!this.#exchange.may_reply) { this.log.debug("A late reply has been ignored"); return; + } else if (this.response.started) { + // E.g. a second `send()`: the response is not sent again + this.log.warn("The response has already been started"); } await send(); this.done = true; diff --git a/packages/binden/src/error.ts b/packages/binden/src/error.ts index da4862cd..91b03142 100644 --- a/packages/binden/src/error.ts +++ b/packages/binden/src/error.ts @@ -10,11 +10,74 @@ export interface IBindenErrorOptions extends ErrorOptions { expose?: boolean | undefined; /** A machine-readable reason (e.g. `deadline`), not sent to the client */ code?: string | undefined; + /** + * Headers that the default error handler sends with the error (e.g. + * `Allow`, `Retry-After` or `WWW-Authenticate`), but none of the framing, + * the representation or the connection + */ + headers?: Readonly> | undefined; +} + +/** The headers that the reply of an error sets itself */ +const forbidden: ReadonlySet = new Set([ + "connection", + "content-encoding", + "content-length", + "content-range", + "content-type", + "transfer-encoding", +]); + +/** A header name: an HTTP token (RFC 9110, section 5.1) */ +const token = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u; + +/** A header value: no control characters but HTAB (RFC 9110, section 5.5) */ +const fieldValue = /^[\t\x20-\x7e\x80-\xff]*$/u; + +/** A frozen copy of valid headers (a `TypeError` otherwise) */ +function copyHeaders( + headers: unknown, +): Readonly> { + if ( + typeof headers !== "object" || + headers === null || + Array.isArray(headers) + ) { + throw new TypeError("`headers` is not an object"); + } + const copy: [string, string | readonly string[]][] = []; + const names = new Set(); + for (const [name, value] of Object.entries(headers)) { + const lower = name.toLowerCase(); + if (!token.test(name)) { + throw new TypeError(`Invalid header name: ${name}`); + } else if (forbidden.has(lower)) { + throw new TypeError(`The \`${name}\` header cannot be set by an error`); + } else if (names.has(lower)) { + throw new TypeError(`Duplicate header: ${name}`); + } + const values: unknown[] = Array.isArray(value) ? value : [value]; + if ( + !values.every( + (item): item is string => + typeof item === "string" && fieldValue.test(item), + ) + ) { + throw new TypeError(`Invalid value of the \`${name}\` header`); + } + names.add(lower); + copy.push([ + name, + Array.isArray(value) ? Object.freeze([...values]) : (value as string), + ]); + } + return Object.freeze(Object.fromEntries(copy)); } /** An HTTP error: its status code (400 to 599) is the status of the reply */ export class BindenError extends Error { readonly #code: string | null; + readonly #headers: Readonly>; readonly #expose: boolean; readonly #json: Record | null; readonly #status: number; @@ -27,6 +90,7 @@ export class BindenError extends Error { json, cause, code, + headers = {}, }: IBindenErrorOptions = {}, ) { if (!Number.isInteger(status)) { @@ -38,6 +102,7 @@ export class BindenError extends Error { } else if (typeof code !== "undefined" && typeof code !== "string") { throw new TypeError("`code` is not a string"); } + const copy = copyHeaders(headers); if (typeof cause === "undefined") { super(message); @@ -47,6 +112,7 @@ export class BindenError extends Error { this.name = "BindenError"; this.#code = code ?? null; + this.#headers = copy; this.#expose = Boolean(expose); this.#json = json ?? null; this.#status = status; @@ -57,6 +123,11 @@ export class BindenError extends Error { return this.#code; } + /** The headers that the default error handler sends with the error */ + public get headers(): Readonly> { + return this.#headers; + } + public get expose(): boolean { return this.#expose; } diff --git a/packages/binden/src/logger.ts b/packages/binden/src/logger.ts index 74f549c5..c24e3285 100644 --- a/packages/binden/src/logger.ts +++ b/packages/binden/src/logger.ts @@ -1,20 +1,23 @@ -import { IncomingMessage, ServerResponse } from "node:http"; import { constants } from "node:os"; -import { Logger } from "@binden/logger"; +import { + type ILogger, + Logger, + serializers as defaultSerializers, +} from "@binden/logger"; import { Context } from "./context.js"; import { BindenError } from "./error.js"; -import { property as get } from "./property.js"; +import { BindenAbortError } from "./exchange.js"; +import { property } from "./property.js"; import { isStatus } from "./status.js"; -/** The request headers that the default logger keeps (others may carry credentials) */ -const requestHeaders = ["host", "user-agent", "content-type", "content-length"]; +/** Where the framework has met an error */ +export type ILocation = + "cleanup" | "error_handler" | "middleware" | "send" | "shutdown"; -/** The response headers that the default logger keeps */ -const responseHeaders = ["content-type", "content-length"]; - -/** The error classes the default logger names (a subclass gets the name of its parent) */ +/** The error classes that a snapshot names (a subclass gets the name of its parent) */ const errorTypes: ReadonlyMap = new Map([ [BindenError.prototype, "BindenError"], + [BindenAbortError.prototype, "BindenAbortError"], [AggregateError.prototype, "AggregateError"], [EvalError.prototype, "EvalError"], [RangeError.prototype, "RangeError"], @@ -25,7 +28,10 @@ const errorTypes: ReadonlyMap = new Map([ [Error.prototype, "Error"], ]); -/** The error codes the default logger keeps: system errors and some stream/HTTP ones */ +/** + * The codes that a snapshot keeps: system errors, some stream and HTTP ones, + * and the codes of the framework + */ const errorCodes: ReadonlySet = new Set([ ...Object.keys(constants.errno), "ERR_HTTP_CONTENT_LENGTH_MISMATCH", @@ -37,10 +43,17 @@ const errorCodes: ReadonlySet = new Set([ "ERR_STREAM_DESTROYED", "ERR_STREAM_PREMATURE_CLOSE", "ERR_STREAM_WRITE_AFTER_END", + "aborted", + "deadline", ]); /** The name of the nearest known error class of a value (never one it defines itself) */ -function typeOf(value: object): string { +function typeOf(value: unknown): string { + if (value === null) { + return "null"; + } else if (typeof value !== "object" && typeof value !== "function") { + return typeof value; + } try { // Bounded: a proxy may return itself, or another proxy every time const seen = new Set(); @@ -59,89 +72,191 @@ function typeOf(value: object): string { return typeof value; } +/** The options of a snapshot */ +export interface ISnapshotOptions { + readonly location: ILocation; + /** The id of the request, if any */ + readonly request_id?: string | undefined; + /** Whether the message, the stack, the cause and any `code` are added */ + readonly details: boolean; + /** The status of the reply, instead of the one of the error */ + readonly status?: number | undefined; +} + /** - * The path of a request-target: never its query or fragment, nor the scheme, - * the authority or the userinfo of an absolute-form target + * What the framework logs of an error: an allowlist of details read without + * trusting the value (whatever is thrown), so that the input of a request + * (which an error message, its stack or its cause may quote) stays out of + * the logs unless `details` is `true` */ -function pathOf(target: string): string { - if (target.startsWith("/")) { - return target.replace(/[?#].*$/su, ""); - } - return URL.canParse(target) ? new URL(target).pathname : ""; +export function snapshot( + error: unknown, + { location, request_id, details, status }: ISnapshotOptions, + depth = 0, +): Record { + const own = property(error, "status"); + const code = property(error, "code"); + const kept = typeof code === "string" && (details || errorCodes.has(code)); + const message = details ? property(error, "message") : null; + const stack = details ? property(error, "stack") : null; + const cause = details && depth < 3 ? property(error, "cause") : null; + return { + type: typeOf(error), + ...(kept ? { code } : {}), + ...(typeof status === "number" + ? { status } + : isStatus(own) + ? { status: own } + : {}), + ...(depth === 0 + ? { + location, + ...(typeof request_id === "string" ? { request_id } : {}), + } + : {}), + ...(typeof message === "string" ? { message } : {}), + ...(typeof stack === "string" ? { stack } : {}), + ...(cause === null || typeof cause === "undefined" + ? {} + : { cause: snapshot(cause, { location, details }, depth + 1) }), + }; } -/** The given headers, when they are set */ -function pick( - names: readonly string[], - header: (name: string) => unknown, -): Record { - const picked: Record = {}; - for (const name of names) { - const value = header(name); - if (typeof value !== "undefined") { - picked[name] = value; +type ILevel = "debug" | "error" | "fatal" | "info" | "trace" | "warn"; + +const levels: readonly ILevel[] = [ + "fatal", + "error", + "warn", + "info", + "debug", + "trace", +]; + +/** Whether a value has the methods of a logger */ +export function isLogger(value: unknown): value is ILogger { + return ( + typeof value === "object" && + value !== null && + [...levels, "child" as const].every( + (method) => typeof property(value, method) === "function", + ) + ); +} + +/** Does nothing (e.g. with a rejection that is observed) */ +function ignore(): void { + // Observed +} + +/** + * A logger whose calls never fail: a method (or `child()`) that throws is + * ignored, and a rejected promise that it returns is observed. A `child()` + * that fails (or that does not return a logger) gives the logger itself. + */ +class SafeLogger implements ILogger { + readonly #logger: ILogger; + + public constructor(logger: ILogger) { + this.#logger = logger; + } + + public fatal(message: string, extra?: Record): void { + this.#call("fatal", message, extra); + } + + public error(message: string, extra?: Record): void { + this.#call("error", message, extra); + } + + public warn(message: string, extra?: Record): void { + this.#call("warn", message, extra); + } + + public info(message: string, extra?: Record): void { + this.#call("info", message, extra); + } + + public debug(message: string, extra?: Record): void { + this.#call("debug", message, extra); + } + + public trace(message: string, extra?: Record): void { + this.#call("trace", message, extra); + } + + public child(bindings: Record): ILogger { + try { + const child: unknown = this.#logger.child(bindings); + if (isLogger(child)) { + return new SafeLogger(child); + } + } catch { + // The logger itself } + return this; } - return picked; + + #call(level: ILevel, message: string, extra?: Record): void { + try { + const result: unknown = + typeof extra === "undefined" + ? this.#logger[level](message) + : this.#logger[level](message, extra); + if (typeof property(result, "then") === "function") { + Promise.resolve(result).catch(ignore); + } + } catch { + // Nothing else to report it to + } + } +} + +/** A logger whose calls never fail (see `SafeLogger`) */ +export function safe(logger: ILogger): ILogger { + return logger instanceof SafeLogger ? logger : new SafeLogger(logger); } /** - * The serializers of the default logger. They log an allowlist of details only, - * which keeps credentials, query strings, request bodies, error details - * (messages, stacks, causes and `json`, which may quote the input) and the own - * fields of a context out of the logs. + * The serializers of the default logger of binden: those of + * `@binden/logger` (an allowlist of the details of a request and a + * response), an `error` that names the errors of binden, a redacted `body` + * and a `context` without the fields that the application sets on it */ export const serializers = { - // The kind of an error: its type, `status`, `expose` and a known `code`. Never - // throws, whatever is thrown. + ...defaultSerializers, + // The type of an error, a known `code` and its `status` error: (error: unknown): Record => { - if (error === null) { - return { type: "null" }; - } else if (typeof error !== "object" && typeof error !== "function") { - return { type: typeof error }; - } - const status = get(error, "status"); - const expose = get(error, "expose"); - const code = get(error, "code"); + const { type, code, status } = snapshot(error, { + location: "middleware", + details: false, + }); return { - type: typeOf(error), - ...(isStatus(status) ? { status } : {}), - ...(typeof expose === "boolean" ? { expose } : {}), - ...(typeof code === "string" && errorCodes.has(code) ? { code } : {}), + type, + ...(typeof code === "string" ? { code } : {}), + ...(typeof status === "number" ? { status } : {}), }; }, body: (): string => "[Redacted]", - // The method, the path (without the query string) and some headers - request: (request: unknown): unknown => { - if (!(request instanceof IncomingMessage)) { - return request; - } - return { - method: request.method, - url: pathOf(request.url ?? ""), - headers: pick(requestHeaders, (name) => request.headers[name]), - }; - }, - response: (response: unknown): unknown => { - if (!(response instanceof ServerResponse)) { - return response; - } - return { - statusCode: response.statusCode, - headers: pick(responseHeaders, (name) => response.getHeader(name)), - }; - }, - // Not the own fields of the context, which the application may have set - context: (input: unknown): unknown => { + context: (input: unknown): Record => { if (!(input instanceof Context)) { - return input; + return { type: typeOf(input) }; } return { id: input.id, - request: serializers.request(input.request), - response: serializers.response(input.response), + request: defaultSerializers.request(input.request), + response: defaultSerializers.response(input.response), }; }, }; -export default new Logger({ serializers }); +let instance: ILogger | null = null; + +/** + * The default logger of binden, created on first use: its level is + * `BINDEN_LOG_LEVEL` (`silent` without it) + */ +export function defaultLogger(): ILogger { + instance ??= new Logger({ serializers }); + return instance; +} diff --git a/packages/binden/test/binden.test.ts b/packages/binden/test/binden.test.ts index c62b5d50..6cad9fe0 100644 --- a/packages/binden/test/binden.test.ts +++ b/packages/binden/test/binden.test.ts @@ -326,7 +326,8 @@ suite("Binden", () => { lines.map(({ msg, trace_id }) => ({ msg, trace_id })), [ { msg: "Custom logger", trace_id: id }, - { msg: "Error should not be exposed", trace_id: id }, + // A client error is logged at `debug` + { msg: "The request has failed", trace_id: id }, ], ); } finally { @@ -349,7 +350,10 @@ suite("Binden", () => { }); deepStrictEqual(response.status, DefaultErrorCode); - ok(logged.output().includes(`"error":{"type":"Error"}`), logged.output()); + ok( + logged.output().includes(`"failure":{"type":"Error","status":500,`), + logged.output(), + ); } finally { await logged.close(); } @@ -410,7 +414,10 @@ suite("Binden", () => { }); deepStrictEqual(response.status, DefaultErrorCode); - ok(logged.output().includes(`"error":{"type":"Error"}`), logged.output()); + ok( + logged.output().includes(`"failure":{"type":"Error","status":500,`), + logged.output(), + ); ok(!logged.output().includes("SECRET"), logged.output()); } finally { await logged.close(); @@ -437,9 +444,7 @@ suite("Binden", () => { deepStrictEqual(response.status, DefaultErrorCode); const output = logged.output(); ok(!output.includes("SECRET"), output); - ok(output.includes(`"error":{"type":"Error"}`), output); - ok(output.includes(`"url":"/path"`), output); - ok(output.includes(`"user-agent":"binden-test"`), output); + ok(output.includes(`"failure":{"type":"Error","status":500,`), output); } finally { await logged.close(); } diff --git a/packages/binden/test/error.test.ts b/packages/binden/test/error.test.ts index e14f9eaf..913b31eb 100644 --- a/packages/binden/test/error.test.ts +++ b/packages/binden/test/error.test.ts @@ -74,6 +74,59 @@ suite("BindenError", () => { ); }); + test("constructor (`headers`)", () => { + const headers = { + "Retry-After": "120", + "WWW-Authenticate": ["Basic", "Bearer"], + }; + const error = new BindenError(503, { headers }); + deepStrictEqual(error.headers, headers); + // A frozen copy + headers["Retry-After"] = "1"; + deepStrictEqual(error.headers["Retry-After"], "120"); + ok(Object.isFrozen(error.headers)); + ok(Object.isFrozen(error.headers["WWW-Authenticate"])); + deepStrictEqual(new BindenError(400).headers, {}); + }); + + test("constructor (invalid `headers`)", () => { + const invalid: [unknown, string][] = [ + [null, "`headers` is not an object"], + ["Allow: GET", "`headers` is not an object"], + [["Allow", "GET"], "`headers` is not an object"], + [{ "Bad Name": "x" }, "Invalid header name: Bad Name"], + [{ "": "x" }, "Invalid header name: "], + [{ "X-A": "x\r\nSet-Cookie: a=1" }, "Invalid value of the `X-A` header"], + [{ "X-A": "x\n" }, "Invalid value of the `X-A` header"], + [{ "X-A": "x\u0000" }, "Invalid value of the `X-A` header"], + [{ "X-A": ["ok", "x\r"] }, "Invalid value of the `X-A` header"], + [{ "X-A": 1 }, "Invalid value of the `X-A` header"], + [{ "X-A": "a", "x-a": "b" }, "Duplicate header: x-a"], + ]; + for (const name of [ + "Content-Length", + "transfer-encoding", + "Content-Encoding", + "CONTENT-RANGE", + "Content-Type", + "Connection", + ]) { + invalid.push([ + { [name]: "x" }, + `The \`${name}\` header cannot be set by an error`, + ]); + } + for (const [headers, message] of invalid) { + throws( + () => + new BindenError(400, { + headers: headers as Record, + }), + new TypeError(message), + ); + } + }); + test("constructor (an unregistered status code)", () => { const error = new BindenError(499); deepStrictEqual(error.status, 499); diff --git a/packages/binden/test/errors.test.ts b/packages/binden/test/errors.test.ts new file mode 100644 index 00000000..3332c9a3 --- /dev/null +++ b/packages/binden/test/errors.test.ts @@ -0,0 +1,251 @@ +import { deepStrictEqual, ok, throws } from "node:assert/strict"; +import { suite, test } from "node:test"; + +import { + Binden, + BindenError, + type Context, + type IBindenOptions, + Middleware, +} from "../index.js"; +import { + capture, + type ICaptured, + type IListening, + listen, + send, +} from "./helpers.js"; + +/** Runs `fn` with a listening application (with a captured debug logger) */ +async function withApp( + options: IBindenOptions, + setup: (app: Binden) => void, + fn: (listening: IListening, captured: ICaptured) => Promise, +): Promise { + const captured = capture(); + const app = new Binden({ ...options, logger: captured.logger }); + setup(app); + const listening = await listen(app); + try { + await fn(listening, captured); + } finally { + await listening.close(); + } +} + +suite("Errors and logging", () => { + test("`logger` and `log_error_details` are validated", () => { + throws( + () => new Binden({ logger: {} as IBindenOptions["logger"] }), + new TypeError("`logger` is not a logger"), + ); + throws( + () => new Binden({ log_error_details: 1 as unknown as boolean }), + new TypeError("`log_error_details` is not a boolean"), + ); + }); + + test("the default handler sends the headers of a `BindenError`", async () => { + await withApp( + {}, + (app) => { + app.use(() => { + throw new BindenError(401, { + headers: { "WWW-Authenticate": ["Basic", 'Bearer realm="api"'] }, + }); + }); + }, + async ({ port }) => { + const response = await send(port); + deepStrictEqual(response.status, 401); + deepStrictEqual( + response.headers["www-authenticate"], + 'Basic, Bearer realm="api"', + ); + }, + ); + }); + + test("the default handler replies `500` to a status that is not of an error", async () => { + const thrown: Partial> = { + "/ok": { status: 200 }, + "/redirect": { status: 302, headers: { Location: "/x" } }, + "/string": { status: "400" }, + "/large": { status: 600 }, + "/float": { status: 404.5 }, + "/teapot": { status: 418, headers: { "X-Duck": "1" } }, + }; + await withApp( + {}, + (app) => { + app.use((context: Context) => { + throw thrown[context.url.pathname]; + }); + }, + async ({ port }) => { + const statuses: unknown[] = []; + for (const path of Object.keys(thrown)) { + const response = await send(port, { path }); + statuses.push([ + path, + response.status, + response.headers["x-duck"] ?? null, + ]); + } + // The headers of a value that is not a `BindenError` are not sent + deepStrictEqual(statuses, [ + ["/ok", 500, null], + ["/redirect", 500, null], + ["/string", 500, null], + ["/large", 500, null], + ["/float", 500, null], + ["/teapot", 418, null], + ]); + }, + ); + }); + + test("a server error is logged at `error`, a client error at `debug`", async () => { + await withApp( + {}, + (app) => { + app.use("/server", () => { + throw new Error("secret-message"); + }); + app.use("/client", () => { + throw new BindenError(404, { message: "secret-message" }); + }); + }, + async ({ port }, captured) => { + await send(port, { path: "/server" }); + await send(port, { path: "/client" }); + const failed = captured.records.filter( + ({ msg }) => msg === "The request has failed", + ); + deepStrictEqual( + failed.map(({ level, failure }) => [level, failure]), + [ + [ + 50, + { + type: "Error", + status: 500, + location: "middleware", + request_id: failed[0]?.trace_id, + }, + ], + [ + 20, + { + type: "BindenError", + status: 404, + location: "middleware", + request_id: failed[1]?.trace_id, + }, + ], + ], + ); + ok(!captured.output().includes("secret"), captured.output()); + }, + ); + }); + + test("`log_error_details` logs the message, the stack, the cause and any code", async () => { + await withApp( + { log_error_details: true }, + (app) => { + app.use(() => { + throw Object.assign( + new Error("the message", { cause: new Error("the cause") }), + { code: "E_CUSTOM" }, + ); + }); + }, + async ({ port }, captured) => { + await send(port); + const { failure } = await captured.waitFor("The request has failed"); + const { message, code, stack, cause } = failure as Record< + string, + unknown + >; + deepStrictEqual([message, code], ["the message", "E_CUSTOM"]); + ok(typeof stack === "string"); + deepStrictEqual( + (cause as Record).message, + "the cause", + ); + }, + ); + }); + + test("a failing cleanup is logged with its location and request id", async () => { + await withApp( + {}, + (app) => { + app.use((context: Context) => { + context.onCleanup(() => { + throw new Error("secret-message"); + }); + return context.send("ok"); + }); + }, + async ({ port }, captured) => { + await send(port); + const record = await captured.waitFor( + "A cleanup of the request has failed", + ); + deepStrictEqual(record.failure, { + type: "Error", + location: "cleanup", + request_id: record.trace_id, + }); + }, + ); + }); + + test("a second reply is logged (`warn`) and not sent", async () => { + await withApp( + {}, + (app) => { + app.use(async (context: Context) => { + await context.send("first"); + await context.send("second"); + }); + }, + async ({ port }, captured) => { + deepStrictEqual((await send(port)).body, "first"); + const record = await captured.waitFor( + "The response has already been started", + ); + deepStrictEqual(record.level, 40); + }, + ); + }); + + test("a middleware that ignores errors logs them", async () => { + class Ignoring extends Middleware { + // eslint-disable-next-line class-methods-use-this -- a failing middleware + public run(): never { + throw new Error("secret-message"); + } + } + await withApp( + {}, + (app) => { + app.use(new Ignoring({ ignore_errors: true }), (context: Context) => + context.send("ok"), + ); + }, + async ({ port }, captured) => { + deepStrictEqual((await send(port)).body, "ok"); + const record = await captured.waitFor("Middleware ignores errors"); + deepStrictEqual(record.middleware, "Ignoring"); + deepStrictEqual(record.failure, { + type: "Error", + location: "middleware", + request_id: record.trace_id, + }); + }, + ); + }); +}); diff --git a/packages/binden/test/exchange.test.ts b/packages/binden/test/exchange.test.ts index 4cbd98b5..f4039c16 100644 --- a/packages/binden/test/exchange.test.ts +++ b/packages/binden/test/exchange.test.ts @@ -208,9 +208,13 @@ suite("Exchange", () => { "A cleanup of the request has failed", ], ); + // Without the details of the errors deepStrictEqual( - errors.map(([, extra]) => (extra?.error as Error).message), - ["A rejected cleanup", "A failing cleanup"], + errors.map(([, extra]) => extra?.failure), + [ + { type: "Error", location: "cleanup" }, + { type: "Error", location: "cleanup" }, + ], ); throws(() => { @@ -230,6 +234,7 @@ suite("Exchange", () => { }; const context = new Context({ request, response, logger: failing }); let ran = false; + let reported = false; context.onCleanup(() => { ran = true; @@ -241,6 +246,27 @@ suite("Exchange", () => { response.end(); await Exchange.for(request, response).finished; ok(ran); + + // Nor a report that fails + const [otherRequest, otherResponse] = await exchange(); + const other = new Context({ + request: otherRequest, + response: otherResponse, + report: (): never => { + reported = true; + throw new Error("The report fails"); + }, + }); + let otherRan = false; + other.onCleanup(() => { + otherRan = true; + }); + other.onCleanup(() => { + throw new Error("A failing cleanup"); + }); + otherResponse.end(); + await Exchange.for(otherRequest, otherResponse).finished; + deepStrictEqual([otherRan, reported], [true, true]); }); test("a cleanup cannot be registered while the cleanups run", async () => { diff --git a/packages/binden/test/fixtures/async-logger.ts b/packages/binden/test/fixtures/async-logger.ts new file mode 100644 index 00000000..2449dba7 --- /dev/null +++ b/packages/binden/test/fixtures/async-logger.ts @@ -0,0 +1,60 @@ +// Run by `process.test.ts`: a logger whose methods return rejected promises +// (or throw) must neither crash the process (`unhandledRejection`) nor keep +// the requests from being handled, whoever logs (the framework or a context) +import { type IncomingMessage, request } from "node:http"; +import type { AddressInfo } from "node:net"; +import { once } from "node:events"; +import type { ILogger } from "@binden/logger"; +import { Binden, BindenError } from "../../index.js"; + +process.on("unhandledRejection", () => { + process.stderr.write("unhandledRejection\n"); + process.exitCode = 1; +}); +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const reject = (): Promise => + Promise.reject(new Error("A rejecting logger")); +const logger: ILogger = { + fatal: reject, + error: reject, + warn: reject, + info: reject, + debug: reject, + trace: reject, + child: () => logger, +}; + +const app = new Binden({ logger }).use((context) => { + context.log.info("Handled", { path: context.url.pathname }); + context.log.child({ middleware: "test" }).debug("A child"); + if (context.url.pathname === "/fails") { + throw new Error("A failure"); + } else if (context.url.pathname === "/client") { + throw new BindenError(400); + } + return context.send("ok"); +}); + +const server = app.createServer().listen(0); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const statuses: number[] = []; +for (const path of ["/", "/fails", "/client"]) { + const response = await new Promise((resolve, failed) => { + request({ agent: false, path, port }, resolve).on("error", failed).end(); + }); + response.resume(); + await once(response, "end"); + statuses.push(response.statusCode ?? 0); +} + +await new Promise((resolve) => { + setImmediate(resolve); +}); +server.close(); +process.stdout.write(`${statuses.join(" ")}\n`); diff --git a/packages/binden/test/logger.test.ts b/packages/binden/test/logger.test.ts index 7542dd06..e0219c8e 100644 --- a/packages/binden/test/logger.test.ts +++ b/packages/binden/test/logger.test.ts @@ -1,15 +1,16 @@ import { deepStrictEqual, ok } from "node:assert/strict"; import { Socket } from "node:net"; import { suite, test } from "node:test"; -import { Logger } from "@binden/logger"; +import { Logger, type ILogger } from "@binden/logger"; import { + BindenAbortError, BindenError, BindenRequest, BindenResponse, Context, } from "../index.js"; -import { serializers } from "../src/logger.js"; +import { isLogger, safe, serializers, snapshot } from "../src/logger.js"; const secrets = [ "secret-token", @@ -32,17 +33,13 @@ function createContext(): Context { request.headers = { host: "localhost", "user-agent": "test", - "content-type": "text/plain", - "content-length": "4", authorization: "Bearer secret-token", - "proxy-authorization": "Basic secret-token", cookie: "session=secret-cookie", "x-api-key": "secret-api-key", }; const response = new BindenResponse(request); response.setHeader("Set-Cookie", "session=secret-set-cookie"); response.setHeader("X-Header", "secret-header"); - response.setHeader("Content-Type", "text/plain"); // A field that the application has set on the context return Object.assign(new Context({ request, response }), { privateSession: "secret-session", @@ -55,56 +52,32 @@ function assertNoSecrets(output: string): void { } } +/** A logger whose methods record their calls */ +function recorder( + method: (message: string, extra?: Record) => unknown, +): ILogger { + const logger: ILogger = { + fatal: method, + error: method, + warn: method, + info: method, + debug: method, + trace: method, + child: (): ILogger => logger, + }; + return logger; +} + suite("serializers", () => { - test("request: an allowlist of details", () => { - const { request } = createContext(); + test("request and response: an allowlist of details", () => { + const { request, response } = createContext(); deepStrictEqual(serializers.request(request), { method: "GET", - url: "/path", - headers: { - host: "localhost", - "user-agent": "test", - "content-type": "text/plain", - "content-length": "4", - }, - }); - - // No URL at all (`url` is optional) - const bare = new BindenRequest(new Socket()); - Reflect.deleteProperty(bare, "url"); - deepStrictEqual(serializers.request(bare), { - method: null, - url: "", - headers: {}, + path: "/path", }); - deepStrictEqual(serializers.request("not a request"), "not a request"); - }); - - test("request: the path of the request-target only", () => { - const url = (target: string): unknown => { - const request = new BindenRequest(new Socket()); - request.url = target; - return (serializers.request(request) as { url: unknown }).url; - }; - - deepStrictEqual(url("/x?token=hidden#fragment"), "/x"); - deepStrictEqual(url("/x#fragment"), "/x"); - // A path, not an authority - deepStrictEqual(url("//evil.test/x?token=hidden"), "//evil.test/x"); - // Not the userinfo, nor the authority of an absolute-form target - deepStrictEqual(url("http://alice:secretPW@trusted.test/x?t=1"), "/x"); - deepStrictEqual(url("*"), ""); - }); - - test("response: an allowlist of details", () => { - const { response } = createContext(); - - deepStrictEqual(serializers.response(response), { - statusCode: 200, - headers: { "content-type": "text/plain" }, - }); - deepStrictEqual(serializers.response("not a response"), "not a response"); + deepStrictEqual(serializers.response(response), { status: 200 }); + deepStrictEqual(serializers.request("secret"), { type: "string" }); }); test("context: not its own fields", () => { @@ -112,14 +85,14 @@ suite("serializers", () => { deepStrictEqual(serializers.context(context), { id: context.id, - request: serializers.request(context.request), - response: serializers.response(context.response), + request: { method: "GET", path: "/path" }, + response: { status: 200 }, }); assertNoSecrets(JSON.stringify(serializers.context(context))); - deepStrictEqual(serializers.context("not a context"), "not a context"); + deepStrictEqual(serializers.context("secret"), { type: "string" }); }); - test("error: no message, stack, cause or json", () => { + test("error: the type, a known code and the status only", () => { const cause = new SyntaxError("secret-cause"); const error = new BindenError(400, { expose: true, @@ -130,128 +103,39 @@ suite("serializers", () => { deepStrictEqual(serializers.error(error), { type: "BindenError", status: 400, - expose: true, - }); - - const system = Object.assign(new Error("secret-message", { cause }), { - code: "ECONNRESET", - }); - deepStrictEqual(serializers.error(system), { - type: "Error", - code: "ECONNRESET", - }); - - const internal = Object.assign(new Error("secret-message"), { - code: "ERR_STREAM_PREMATURE_CLOSE", - }); - deepStrictEqual(serializers.error(internal), { - type: "Error", - code: "ERR_STREAM_PREMATURE_CLOSE", - }); - - const invalid = Object.assign(new TypeError("secret-message"), { - code: 42, - status: "400", - expose: "true", }); - deepStrictEqual(serializers.error(invalid), { type: "TypeError" }); - - // Only known codes: any other `code` may come from the input - for (const code of [ - "secret-code", - "SECRET-CODE", - "ESECRET", - "ERR_SECRET_ABC123", - ]) { - deepStrictEqual(serializers.error(Object.assign(new Error(), { code })), { - type: "Error", - }); - } - deepStrictEqual( - serializers.error({ status: 400, expose: true, message: "secret" }), - { type: "object", status: 400, expose: true }, + serializers.error(new BindenError(503, { code: "deadline" })), + { type: "BindenError", code: "deadline", status: 503 }, + ); + deepStrictEqual( + serializers.error(new BindenError(400, { code: "secret-code" })), + { type: "BindenError", status: 400 }, + ); + deepStrictEqual( + serializers.error( + Object.assign(new Error("secret-message"), { code: "ECONNRESET" }), + ), + { type: "Error", code: "ECONNRESET" }, ); - deepStrictEqual(serializers.error({ status: 999 }), { type: "object" }); - deepStrictEqual(serializers.error({ status: "400" }), { type: "object" }); - deepStrictEqual(serializers.error({ status: 499 }), { - type: "object", - status: 499, + deepStrictEqual(serializers.error(new BindenAbortError("deadline")), { + type: "BindenAbortError", }); - deepStrictEqual(serializers.error("secret-message"), { type: "string" }); - deepStrictEqual(serializers.error(null), { type: "null" }); - }); - - test("error: the type of a known error class only", () => { - // Names the application (or the input) chooses are not logged class SECRET_CLASS extends RangeError {} deepStrictEqual(serializers.error(new SECRET_CLASS()), { type: "RangeError", }); - - const tagged = Object.assign(new Error(), { - [Symbol.toStringTag]: "SECRET_TAG", - name: "SECRET_NAME", - }); - deepStrictEqual(serializers.error(tagged), { type: "Error" }); - - const errors = [ - new AggregateError([]), - new EvalError(), - new ReferenceError(), - new SyntaxError(), - new URIError(), - ]; - deepStrictEqual( - errors.map((error) => serializers.error(error).type), - [ - "AggregateError", - "EvalError", - "ReferenceError", - "SyntaxError", - "URIError", - ], - ); - }); - - test("error: never throws", () => { - const anonymous = Object.assign(new Error("secret-message"), { - constructor: null, - }); - deepStrictEqual(serializers.error(anonymous), { type: "Error" }); - - const getters = new Error("secret-message"); - for (const key of ["constructor", "status", "expose", "code"]) { - Object.defineProperty(getters, key, { - get(): never { - throw new Error("getter"); - }, - }); - } - deepStrictEqual(serializers.error(getters), { type: "Error" }); - - const prototypeless = Object.assign(Object.create(null) as object, { - status: 404, - }); - deepStrictEqual(serializers.error(prototypeless), { - type: "object", - status: 404, - }); - - const { proxy, revoke } = Proxy.revocable(new Error("secret-message"), {}); - revoke(); - deepStrictEqual(serializers.error(proxy), { type: "object" }); - + deepStrictEqual(serializers.error(null), { type: "null" }); + deepStrictEqual(serializers.error("secret"), { type: "string" }); const thrower = (): never => { throw new Error("trap"); }; + // A proxy that refuses to tell anything const traps = new Proxy(new Error("secret-message"), { get: thrower, getPrototypeOf: thrower, }); deepStrictEqual(serializers.error(traps), { type: "object" }); - - deepStrictEqual(serializers.error(thrower), { type: "function" }); }); test("body", () => { @@ -281,3 +165,150 @@ suite("serializers", () => { ok(lines.join("").includes("[Redacted]")); }); }); + +suite("snapshot()", () => { + test("an allowlist of details, with the location and the request id", () => { + const error = Object.assign(new TypeError("secret-message"), { + code: "ERR_SECRET", + status: 409, + cause: new Error("secret-cause"), + }); + deepStrictEqual( + snapshot(error, { + location: "middleware", + request_id: "id", + details: false, + }), + { + type: "TypeError", + status: 409, + location: "middleware", + request_id: "id", + }, + ); + // The status of the reply + deepStrictEqual( + snapshot(error, { location: "send", details: false, status: 500 }), + { type: "TypeError", status: 500, location: "send" }, + ); + deepStrictEqual( + snapshot(Object.assign(new Error(), { status: 99 }), { + location: "cleanup", + details: false, + }), + { type: "Error", location: "cleanup" }, + ); + }); + + test("the message, the stack, the cause and any code with `details`", () => { + const error = new Error("first", { + cause: Object.assign(new RangeError("second"), { + code: "E_SECOND", + cause: new Error("third", { + cause: new Error("fourth", { cause: "fifth" }), + }), + }), + }); + const result = snapshot(error, { location: "middleware", details: true }); + const second = result.cause as Record; + const third = second.cause as Record; + const fourth = third.cause as Record; + deepStrictEqual( + [ + result.message, + second.message, + second.code, + third.message, + fourth.message, + ], + ["first", "second", "E_SECOND", "third", "fourth"], + ); + ok(typeof result.stack === "string"); + // Bounded, and without the location in a cause + ok(!("cause" in fourth)); + ok(!("location" in second)); + // `null` is no cause + ok( + !( + "cause" in + snapshot(new Error("x", { cause: null }), { + location: "middleware", + details: true, + }) + ), + ); + }); +}); + +suite("safe()", () => { + test("a logger that throws, or that rejects, is ignored", async () => { + const calls: unknown[][] = []; + const throwing = safe( + recorder((...args: unknown[]): never => { + calls.push(args); + throw new Error("A failing logger"); + }), + ); + throwing.fatal("fatal"); + throwing.error("error", { a: 1 }); + throwing.warn("warn"); + throwing.info("info"); + throwing.debug("debug"); + throwing.trace("trace"); + deepStrictEqual(calls, [ + ["fatal"], + ["error", { a: 1 }], + ["warn"], + ["info"], + ["debug"], + ["trace"], + ]); + + let observed = false; + const rejecting = safe( + recorder(() => ({ + then(_resolve: unknown, reject: (error: Error) => void): void { + observed = true; + reject(new Error("A rejecting logger")); + }, + })), + ); + rejecting.error("error"); + await new Promise((resolve) => { + setImmediate(resolve); + }); + ok(observed); + // Not wrapped twice + ok(safe(rejecting) === rejecting); + }); + + test("a `child()` that fails gives the logger itself", () => { + const logged: string[] = []; + const base = recorder((message: string) => { + logged.push(message); + }); + const throwing = safe({ + ...base, + child: (): never => { + throw new Error("No child"); + }, + }); + ok(throwing.child({ id: 1 }) === throwing); + const invalid = safe({ + ...base, + child: (): ILogger => ({}) as ILogger, + }); + ok(invalid.child({ id: 1 }) === invalid); + const valid = safe(base).child({ id: 1 }); + valid.info("child"); + deepStrictEqual(logged, ["child"]); + }); + + test("isLogger()", () => { + ok(isLogger(new Logger({ level: "silent" }))); + ok(isLogger(recorder(() => null))); + ok(!isLogger(null)); + ok(!isLogger("logger")); + ok(!isLogger({ ...recorder(() => null), child: null })); + }); +}); diff --git a/packages/binden/test/process.test.ts b/packages/binden/test/process.test.ts index 80210f72..d2eac83e 100644 --- a/packages/binden/test/process.test.ts +++ b/packages/binden/test/process.test.ts @@ -50,6 +50,14 @@ suite("Process safety", () => { }); }); + test("a logger that rejects does not crash the process", () => { + deepStrictEqual(run("async-logger.ts"), { + status: 0, + stdout: "200 500 400\n", + stderr: "", + }); + }); + test("a logger whose `child()` throws does not crash the process", () => { deepStrictEqual(run("throwing-logger.ts"), { status: 0, diff --git a/packages/binden/test/shutdown.test.ts b/packages/binden/test/shutdown.test.ts index da7dcafc..d4282585 100644 --- a/packages/binden/test/shutdown.test.ts +++ b/packages/binden/test/shutdown.test.ts @@ -3,7 +3,6 @@ import { once } from "node:events"; import { createServer, type Server } from "node:http"; import { connect, type Socket } from "node:net"; import { suite, test } from "node:test"; -import type { Logger } from "@binden/logger"; import { Binden, @@ -246,7 +245,8 @@ suite("shutdown()", () => { within(shutdown(server, { cleanup_timeout: 30 })), new Error("The cleanup of the requests has not completed within 30 ms"), ); - await captured.waitFor("The shutdown has failed"); + const record = await captured.waitFor("The shutdown has failed"); + deepStrictEqual(record.failure, { type: "Error", location: "shutdown" }); }); test("before `listen()`, and once closed, waits for the cleanups only", async () => { @@ -361,16 +361,19 @@ suite("callback()", () => { ); // Even with a logger that fails + const fail = (): never => { + throw new Error("A failing logger"); + }; const failing = new Binden({ logger: { - ...captured.logger, - error: (): never => { - throw new Error("A failing logger"); - }, - child: (): never => { - throw new Error("A failing logger"); - }, - } as unknown as Logger, + fatal: fail, + error: fail, + warn: fail, + info: fail, + debug: fail, + trace: fail, + child: fail, + }, }); const server = createServer(); server.on("request", failing.callback(server)); From e1c4b1f96c4cf6a7b02cf116bb47034ede2d140b Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:56:04 +0300 Subject: [PATCH 144/675] feat: add an access log in binden An application had no built-in record of the requests it served: the operators needed their own middleware, which sees neither the aborted requests nor the bytes written after a compression. The `access_log` option (off by default) logs every request once, when its response is closed, also when it has been aborted: its method, its route (the templates of the matched mounts and of the latest `route()` or `use()` layer that has run a middleware, or ``), its status (`null` when it was aborted before its headers), its duration, the bytes of its body as written (after a content coding, without the headers, and none for `HEAD`, 1xx, 204 and 304), its id and whether it was aborted. The record is logged at `info`, or at `debug` with `{ level: "debug" }`. The path of a request, which may name a user or a resource, is added only with `{ path: "path" }`, without its query. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 22 ++ packages/binden/src/binden.ts | 137 ++++++++++- packages/binden/src/exchange.ts | 14 ++ packages/binden/src/property.ts | 11 + packages/binden/test/access-log.test.ts | 304 ++++++++++++++++++++++++ 5 files changed, 483 insertions(+), 5 deletions(-) create mode 100644 packages/binden/test/access-log.test.ts diff --git a/packages/binden/README.md b/packages/binden/README.md index 83c9dda1..0e066f64 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -16,6 +16,7 @@ An application is a `Binden`: the root [stack](#stack-and-routing), whose option | Option | Default | Description | | ------------------------------------------------------------------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `access_log` | `false` | Logs every request once its response is closed: `true`, or `{ level, path }` (see [Access log](#access-log)) | | `auto_head` | `true` | A router without `HEAD` middlewares handles `HEAD` with its `GET` middlewares (see [Router](#router)) | | `error_handler` | none | Replaces the default reply to an error (see [Errors](#errors)) | | `error_handler_timeout` | `5000` | The milliseconds that the `error_handler` has to settle (an integer from 1 to 2³¹−1) | @@ -182,6 +183,27 @@ const verbose = new Logger({ const debugApp = new Binden({ logger: verbose, log_error_details: true }); ``` +### Access log + +With the `access_log` option, every request is logged once (at `info`, or at `debug` with `{ level: "debug" }`) when its response is closed, also when it has been aborted: + +| Field | Description | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `method` | The method of the request | +| `route` | The templates of the matched mounts and of the latest `route()` or `use()` layer that has run a middleware (e.g. `/api/users/:id`), or `` | +| `path` | The path of the request, without its query (with `{ path: "path" }` only: it may name a user or a resource) | +| `status` | The status of the response, or `null` when it has been aborted before its headers were sent | +| `duration_ms` | The milliseconds from the request to the close of its response | +| `body_bytes` | The bytes of the body, as written (e.g. compressed), without the headers | +| `id` | The id of the request | +| `aborted` | Whether the request has been aborted (see [Lifecycle](#lifecycle)) | + +```typescript +import { Binden } from "binden"; + +const app = new Binden({ access_log: { level: "info", path: "template" } }); +``` + ### Lifecycle Every request has an abort signal, `context.signal`, aborted at most once (its `reason` is a `BindenAbortError`, whose `reason` says why): diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index 57005e9f..8d6adb5d 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -13,7 +13,7 @@ import { snapshot, } from "./logger.js"; import { Middleware, type IMiddleware } from "./middleware.js"; -import { property } from "./property.js"; +import { pathOf, property } from "./property.js"; import { BindenRequest, type ITrustProxy } from "./request.js"; import { BindenResponse, ct_json } from "./response.js"; import { matchPrefix, matchRoute, type IRoutingOptions } from "./route.js"; @@ -29,6 +29,23 @@ export type IErrorHandler> = ( error: unknown, ) => Promise | void; +/** The options of the access log (see `IBindenBaseOptions.access_log`) */ +export interface IAccessLogOptions { + /** The level of its records (`info`) */ + level?: "debug" | "info" | undefined; + /** + * `template` (the default) logs the route only; `path` adds the path of the + * request (without its query), which may name a user or a resource + */ + path?: "path" | "template" | undefined; +} + +/** The options of the access log, with their defaults */ +interface IAccessLog { + readonly level: "debug" | "info"; + readonly path: "path" | "template"; +} + /** The options of `Binden`, but its `state` */ export interface IBindenBaseOptions< State extends object = Record, @@ -64,6 +81,11 @@ export interface IBindenBaseOptions< log_error_details?: boolean | undefined; /** The matching of the patterns of `route()` and `mount()` */ routing?: IRoutingOptions | undefined; + /** + * Whether every request is logged once its response is closed (`false`): + * `true` or the options of the access log + */ + access_log?: IAccessLogOptions | boolean | undefined; /** * The proxies whose `Forwarded` elements `request.protocol` trusts: none * (`false`, the default), all of them (`true`) or the given number of the @@ -105,6 +127,8 @@ interface ILevel { readonly template: string; /** The rest of the path, which the items of a mount see */ readonly rest: string; + /** The template of the layer that the access log names (`""` for none) */ + readonly route: string; } /** @@ -120,6 +144,7 @@ export class Binden< readonly #timeout: number; readonly #logger: ILogger; readonly #details: boolean; + readonly #access: IAccessLog | null; readonly #routing: Required; readonly #state: (() => object) | null; readonly #Request: typeof BindenRequest; @@ -139,6 +164,7 @@ export class Binden< auto_head = true, logger, log_error_details = false, + access_log = false, routing = {}, state, timeout = 0, @@ -168,6 +194,7 @@ export class Binden< throw new TypeError("`routing` is not an object"); } const { case_sensitive = true, strict = true } = routing; + this.#access = Binden.#accessOptions(access_log); Binden.#boolean("routing.case_sensitive", case_sensitive); Binden.#boolean("routing.strict", strict); markRoot(this); @@ -282,6 +309,9 @@ export class Binden< }); const exchange = Exchange.for(request, response); track(server, exchange, response); + if (this.#access) { + Binden.#accessLog(context, exchange, this.#access); + } if (this.#timeout > 0) { exchange.setTimeout(this.#timeout); } @@ -437,6 +467,12 @@ export class Binden< const middlewares = item instanceof Router ? this.#methodOf(item, run, allow) : [item]; for (const middleware of middlewares) { + // The route of the access log: the template of the latest layer + // that has run a middleware + const route = exchange.mount_template + level.route; + if (route !== "") { + exchange.route = route; + } context = await this.#runMiddleware(middleware, context, exchange); run.context = context; if (context.done) { @@ -456,20 +492,30 @@ export class Binden< * and the prefix and the rest of the path of a mount), or `null` */ #match(layer: ILayer, path: string): ILevel | null { - const none = { params: {}, prefix: "", template: "", rest: path }; + const none = { + params: {}, + prefix: "", + template: "", + rest: path, + route: "", + }; if (layer.kind === "use") { const { path: match } = layer; return match === null || (match instanceof RegExp ? match.test(path) : match === path) - ? none + ? { ...none, route: match === null ? "" : String(match) } : null; } else if (layer.kind === "route") { const params = matchRoute(layer.pattern, path, this.#routing); - return params === null ? null : { ...none, params }; + return params === null + ? null + : { ...none, params, route: layer.pattern.source }; } const { pattern } = layer; const match = matchPrefix(pattern, path, this.#routing); - return match === null ? null : { ...match, template: pattern.source }; + return match === null + ? null + : { ...match, template: pattern.source, route: "" }; } /** @@ -862,6 +908,87 @@ export class Binden< } } + /** The options of the access log, or `null` when it is off */ + static #accessOptions(option: unknown): IAccessLog | null { + if (option === false) { + return null; + } else if (option === true) { + return { level: "info", path: "template" }; + } else if (typeof option !== "object" || option === null) { + throw new TypeError("`access_log` is not a boolean or an object"); + } + const { level = "info", path = "template" } = option as Record< + string, + unknown + >; + if (level !== "info" && level !== "debug") { + throw new TypeError("`access_log.level` is not `info` or `debug`"); + } else if (path !== "template" && path !== "path") { + throw new TypeError("`access_log.path` is not `template` or `path`"); + } + return { level, path }; + } + + /** + * Logs a request once its response is closed: its method, route (the + * templates of the matched mounts and layer, or ``), status + * (`null` when the headers have not been sent), duration, the bytes of the + * body (as written, e.g. compressed), id and whether it has been aborted + */ + static #accessLog( + context: Context, + exchange: Exchange, + { level, path }: IAccessLog, + ): void { + const { request, response } = context; + const start = performance.now(); + let bytes = 0; + const count = (chunk: unknown, encoding: unknown): void => { + const { statusCode } = response; + if ( + request.method === "HEAD" || + statusCode < 200 || + statusCode === 204 || + statusCode === 304 + ) { + // Node.js does not send a body + return; + } else if (typeof chunk === "string") { + const coding = typeof encoding === "string" ? encoding : "utf8"; + bytes += Buffer.byteLength(chunk, coding as BufferEncoding); + } else if (ArrayBuffer.isView(chunk)) { + bytes += chunk.byteLength; + } + }; + // Replaced on the response itself, before any middleware (e.g. a + // compression that writes the encoded body with them) + const write = response.write.bind(response); + const end = response.end.bind(response); + response.write = ((...args: unknown[]): boolean => { + count(args[0], args[1]); + return Reflect.apply(write, response, args) as boolean; + }) as typeof response.write; + response.end = ((...args: unknown[]): typeof response => { + count(args[0], args[1]); + return Reflect.apply(end, response, args) as typeof response; + }) as typeof response.end; + + response.once("close", () => { + const duration = performance.now() - start; + context.log[level]("The request has completed", { + method: request.method, + route: exchange.route ?? "", + // A request of a server always has its request-target + ...(path === "path" ? { path: pathOf(request.url as string) } : {}), + status: response.headersSent ? response.statusCode : null, + duration_ms: Math.round(duration * 1000) / 1000, + body_bytes: bytes, + id: context.id, + aborted: exchange.signal.aborted, + }); + }); + } + /** A boolean option */ static #boolean(name: string, value: unknown): void { if (typeof value !== "boolean") { diff --git a/packages/binden/src/exchange.ts b/packages/binden/src/exchange.ts index d9eb4389..bdd5f19e 100644 --- a/packages/binden/src/exchange.ts +++ b/packages/binden/src/exchange.ts @@ -59,6 +59,7 @@ export class Exchange { #params: Readonly>; #mount_path: string; #mount_template: string; + #route: string | null; private constructor( response: ServerResponse, @@ -86,6 +87,7 @@ export class Exchange { this.#params = Object.freeze({}); this.#mount_path = ""; this.#mount_template = ""; + this.#route = null; // `close` follows `finish`, unless the connection is closed before the // response is complete @@ -197,6 +199,18 @@ export class Exchange { }; } + /** + * The route that the access log names: the templates of the matched mounts + * and of the latest layer that has run a middleware (`null` for none) + */ + public get route(): string | null { + return this.#route; + } + + public set route(route: string) { + this.#route = route; + } + public get phase(): IPhase { return this.#phase; } diff --git a/packages/binden/src/property.ts b/packages/binden/src/property.ts index a2c53033..3fe2f00f 100644 --- a/packages/binden/src/property.ts +++ b/packages/binden/src/property.ts @@ -9,3 +9,14 @@ export function property(value: unknown, key: string): unknown { return null; } } + +/** + * The path of a request-target: never its query or fragment, nor the scheme, + * the authority or the userinfo of an absolute-form target + */ +export function pathOf(target: string): string { + if (target.startsWith("/")) { + return target.replace(/[?#].*$/su, ""); + } + return URL.canParse(target) ? new URL(target).pathname : ""; +} diff --git a/packages/binden/test/access-log.test.ts b/packages/binden/test/access-log.test.ts new file mode 100644 index 00000000..48f9ef73 --- /dev/null +++ b/packages/binden/test/access-log.test.ts @@ -0,0 +1,304 @@ +import { deepStrictEqual, ok, rejects, throws } from "node:assert/strict"; +import { Readable } from "node:stream"; +import { suite, test } from "node:test"; + +import { + Binden, + type Context, + type IBindenOptions, + Router, + Stack, +} from "../index.js"; +import { + capture, + type ICaptured, + type IListening, + listen, + never, + send, + sleep, +} from "./helpers.js"; + +const message = "The request has completed"; + +/** Runs `fn` with a listening application (with a captured debug logger) */ +async function withApp( + options: IBindenOptions, + setup: (app: Binden) => void, + fn: (listening: IListening, captured: ICaptured) => Promise, +): Promise { + const captured = capture(); + const app = new Binden({ ...options, logger: captured.logger }); + setup(app); + const listening = await listen(app); + try { + await fn(listening, captured); + } finally { + await listening.close(); + } +} + +/** The access records written so far, without their duration and id */ +function records(captured: ICaptured): Record[] { + return captured.records + .filter(({ msg }) => msg === message) + .map((record) => { + const { duration_ms, trace_id, id, path } = record; + ok(typeof duration_ms === "number" && duration_ms >= 0); + ok(id === trace_id && typeof id === "string"); + const { level, method, route, status, body_bytes, aborted } = record; + return { + level, + method, + route, + ...(typeof path === "undefined" ? {} : { path }), + status, + body_bytes, + aborted, + }; + }); +} + +/** Waits for the access records of `count` requests */ +async function waitFor(captured: ICaptured, count: number): Promise { + const deadline = Date.now() + 5000; + while (records(captured).length < count && Date.now() < deadline) { + await sleep(5); + } +} + +suite("Access log", () => { + test("`access_log` is validated", () => { + const invalid: [unknown, string][] = [ + ["yes", "`access_log` is not a boolean or an object"], + [null, "`access_log` is not a boolean or an object"], + [{ level: "warn" }, "`access_log.level` is not `info` or `debug`"], + [{ path: "full" }, "`access_log.path` is not `template` or `path`"], + ]; + for (const [access_log, error] of invalid) { + throws( + () => new Binden({ access_log: access_log as boolean }), + new TypeError(error), + ); + } + }); + + test("is off by default", async () => { + await withApp( + {}, + (app) => { + app.use((context: Context) => context.send("ok")); + }, + async ({ port }, captured) => { + await send(port); + await sleep(20); + deepStrictEqual(records(captured), []); + }, + ); + }); + + test("one record of every request, with its route", async () => { + await withApp( + { access_log: true }, + (app) => { + const users = new Stack().route("/:id", (context: Context) => + context.send("user"), + ); + app + .route("/items/:id", (context: Context) => context.send("item")) + .mount("/api/users", users) + .use( + "/static", + new Router().get((context: Context) => context.send("s")), + ) + .use(/^\/regexp$/u, (context: Context) => context.send("r")) + .use("/raw", (context: Context) => { + // Node.js does not send these bodies + if (context.request.method === "GET") { + context.response.statusCode = 204; + } + context.response.end("abc"); + context.done = true; + }); + }, + async ({ port }, captured) => { + for (const path of [ + "/items/1?secret=1", + "/api/users/2", + "/static", + "/regexp", + "/nothing", + ]) { + await send(port, { path }); + } + await send(port, { path: "/static", method: "HEAD" }); + await send(port, { path: "/raw", method: "HEAD" }); + await send(port, { path: "/raw" }); + await waitFor(captured, 8); + const base = { level: 30, method: "GET", aborted: false }; + deepStrictEqual(records(captured), [ + { ...base, route: "/items/:id", status: 200, body_bytes: 4 }, + { ...base, route: "/api/users/:id", status: 200, body_bytes: 4 }, + { ...base, route: "/static", status: 200, body_bytes: 1 }, + { ...base, route: "/^\\/regexp$/u", status: 200, body_bytes: 1 }, + { ...base, route: "", status: 404, body_bytes: 0 }, + // No body is sent + { + ...base, + method: "HEAD", + route: "/static", + status: 200, + body_bytes: 0, + }, + { + ...base, + method: "HEAD", + route: "/raw", + status: 200, + body_bytes: 0, + }, + { ...base, route: "/raw", status: 204, body_bytes: 0 }, + ]); + ok(!captured.output().includes("secret")); + }, + ); + }); + + test("the level, and the path of the request", async () => { + await withApp( + { access_log: { level: "debug", path: "path" } }, + (app) => { + app.route("/users/:id", (context: Context) => context.send("ok")); + }, + async ({ port }, captured) => { + await send(port, { path: "/users/a%20b?token=secret" }); + // An absolute-form request-target, and `*` + await send(port, { + path: `http://user:secret@localhost:${port}/users/1?token=secret`, + }); + await send(port, { path: "*", method: "OPTIONS" }); + await waitFor(captured, 3); + const base = { + level: 20, + method: "GET", + route: "/users/:id", + status: 200, + body_bytes: 2, + aborted: false, + }; + deepStrictEqual(records(captured), [ + { ...base, path: "/users/a%20b" }, + // 400 (the userinfo), and its path without the userinfo + { + ...base, + route: "", + path: "/users/1", + status: 400, + body_bytes: 0, + }, + { + ...base, + method: "OPTIONS", + route: "", + path: "", + status: 404, + body_bytes: 0, + }, + ]); + ok(!captured.output().includes("secret")); + }, + ); + }); + + test("the bytes of a streamed body", async () => { + await withApp( + { access_log: true }, + (app) => { + app.use((context: Context) => + context.send(Readable.from([Buffer.from("ab"), Buffer.from("cde")])), + ); + app.use(() => { + // Not reached + }); + }, + async ({ port }, captured) => { + deepStrictEqual((await send(port)).body, "abcde"); + await waitFor(captured, 1); + deepStrictEqual(records(captured)[0]?.body_bytes, 5); + }, + ); + await withApp( + { access_log: true }, + (app) => { + app.use((context: Context) => { + context.response.write("é", "latin1"); + context.response.write("é"); + context.response.write(new Uint8Array(3)); + context.response.end(() => { + // Ended + }); + context.done = true; + }); + }, + async ({ port }, captured) => { + await send(port); + await waitFor(captured, 1); + deepStrictEqual(records(captured)[0]?.body_bytes, 6); + }, + ); + }); + + test("a request aborted before its headers has no status", async () => { + const started = Promise.withResolvers(); + await withApp( + { access_log: true }, + (app) => { + app.use(async () => { + started.resolve(null); + await never(); + }); + }, + async ({ port }, captured) => { + const pending = rejects(send(port, { ms: 100 })); + await started.promise; + await pending; + await waitFor(captured, 1); + deepStrictEqual(records(captured), [ + { + level: 30, + method: "GET", + route: "", + status: null, + body_bytes: 0, + aborted: true, + }, + ]); + }, + ); + }); + + test("a request that has passed its deadline", async () => { + await withApp( + { access_log: true, timeout: 20 }, + (app) => { + app.use(async () => { + await never(); + }); + }, + async ({ port }, captured) => { + deepStrictEqual((await send(port)).status, 503); + await waitFor(captured, 1); + deepStrictEqual(records(captured), [ + { + level: 30, + method: "GET", + route: "", + status: 503, + body_bytes: 0, + aborted: true, + }, + ]); + }, + ); + }); +}); From 51f39489ee88822ee1ad6814c33d996d0cd45b33 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:58:11 +0300 Subject: [PATCH 145/675] fix: export the helper types of the route parameters in binden `IRouteParams` refers to the types that split a pattern into its segments and name their parameters. TypeDoc treats its warnings as errors, and warns about a type that an exported one refers to without exporting it, so the documentation could not be built. They are exported as `IRouteSegments` and `IRouteParamName`, which also let an application derive the parameters of its own patterns. Co-Authored-By: Claude Opus 5.5 --- packages/binden/index.ts | 7 ++++++- packages/binden/src/route.ts | 19 ++++++++++--------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/binden/index.ts b/packages/binden/index.ts index 16532d55..41fe300f 100644 --- a/packages/binden/index.ts +++ b/packages/binden/index.ts @@ -30,7 +30,12 @@ export { type IServer, type IShutdownOptions, } from "./src/shutdown.js"; -export type { IRouteParams, IRoutingOptions } from "./src/route.js"; +export type { + IRouteParamName, + IRouteParams, + IRouteSegments, + IRoutingOptions, +} from "./src/route.js"; export { Stack, type IStack, diff --git a/packages/binden/src/route.ts b/packages/binden/src/route.ts index 06a5b024..2976f592 100644 --- a/packages/binden/src/route.ts +++ b/packages/binden/src/route.ts @@ -1,18 +1,19 @@ import type { IParams } from "./context.js"; import { BindenError } from "./error.js"; -/** The segments of a path pattern */ -type ISegments = +/** The segments of a route pattern (a union of strings) */ +export type IRouteSegments = Pattern extends `${infer Head}/${infer Tail}` - ? Head | ISegments + ? Head | IRouteSegments : Pattern; -/** The name of a `:name` or `*name` segment */ -type IParamName = Segment extends `:${infer Name}` - ? Name - : Segment extends `*${infer Name}` +/** The name of the parameter of a `:name` or `*name` segment (`never` for a literal) */ +export type IRouteParamName = + Segment extends `:${infer Name}` ? Name - : never; + : Segment extends `*${infer Name}` + ? Name + : never; /** * The parameters of a route pattern (e.g. `{ id: string }` for @@ -20,7 +21,7 @@ type IParamName = Segment extends `:${infer Name}` */ export type IRouteParams = string extends Pattern ? IParams - : Record>, string>; + : Record>, string>; /** The options of the matching of `route()` and `mount()` patterns */ export interface IRoutingOptions { From 62ca690c286b531f3a1810f13b6dfbd28a217b08 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 00:58:25 +0300 Subject: [PATCH 146/675] docs: list the request members of the context in binden The signal, the deadline, the cleanups, the state, the parameters and the mount path of a request were only described in the sections of their features. The list of the members of the context now points to them, with an example that uses them together. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/binden/README.md b/packages/binden/README.md index 0e066f64..84f0cdb0 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -306,6 +306,22 @@ const { response } = context; const { id } = context; ``` +- `.signal`, `.setTimeout()` and `.onCleanup()` - the abort signal, the deadline and the cleanups of the request (see [Lifecycle](#lifecycle)) +- `.state` - the state of the request, shared by every context of the request (see the `state` option of [Binden](#binden)) +- `.params` and `.mount_path` - the parameters of the matched route and mounts, and the matched mount prefixes (see [Stack and routing](#stack-and-routing)) + +```typescript +import { Binden } from "binden"; + +new Binden().route("/users/:id", async (context) => { + context.setTimeout(5000); + context.onCleanup(() => { + context.log.debug("Done", { id: context.params.id }); + }); + await context.json({ id: context.params.id, state: context.state }); +}); +``` + - `.done` - stop passing the `context` to the following middlewares. The middleware should have started the response by then (e.g. `.send()` or a stream piped into it, even if not awaited); otherwise an empty response is sent, with the status and headers set so far. A middleware that starts the response without setting `.done` must finish it: the request is not passed on as `404 Not Found`. ```typescript From d99dfcde5842e3a920e95d55aed974dd7a9f1a72 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 01:10:42 +0300 Subject: [PATCH 147/675] feat!: take the request and response options in binden The requests and the responses could have their options (trusted proxies of every form, allowed hosts, request ids, encoded slashes, query limit and the dynamic `ETag`) only through subclasses: `Binden` still passed the old numeric `trust_proxy` alone, through a deprecated getter. `Binden` now takes these options, checks them once in its constructor with `validateRequestOptions()` and `validateResponseOptions()`, and gives them to a request class and a response class of its own, which override the protected `options` getters. `createServer()`, `createSecureServer()`, `serverOptions()` and `callback()` use these classes, and `callback()` refuses the requests and the responses of another application. An invalid request-target, a host that is not allowed, an invalid one or several `Host` field lines are refused with a 400 before the stack runs, and a callback option that does not return a boolean is a 500; the access log and the error snapshots do not read the URL of such a request. BREAKING CHANGE: `BindenRequest` no longer has the deprecated protected `trust_proxy` getter: a subclass overrides `options` with the result of `validateRequestOptions()` instead. `serverOptions().ServerResponse` is a subclass of `BindenResponse` of the application. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 3 +- packages/binden/src/binden.ts | 73 ++++++--- packages/binden/src/request.ts | 31 ++-- packages/binden/test/options.test.ts | 217 ++++++++++++++++++++++++++ packages/binden/test/request.test.ts | 16 -- packages/binden/test/shutdown.test.ts | 17 +- 6 files changed, 300 insertions(+), 57 deletions(-) create mode 100644 packages/binden/test/options.test.ts diff --git a/packages/binden/README.md b/packages/binden/README.md index 84f0cdb0..fabf2dc8 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -20,6 +20,7 @@ An application is a `Binden`: the root [stack](#stack-and-routing), whose option | `auto_head` | `true` | A router without `HEAD` middlewares handles `HEAD` with its `GET` middlewares (see [Router](#router)) | | `error_handler` | none | Replaces the default reply to an error (see [Errors](#errors)) | | `error_handler_timeout` | `5000` | The milliseconds that the `error_handler` has to settle (an integer from 1 to 2³¹−1) | +| `etag` | `false` | A strong `ETag` for the `200` responses to `GET` and `HEAD`, and `304 Not Modified` when `If-None-Match` matches it (see the dynamic `ETag` of [BindenResponse](#bindenresponse)) | | `timeout` | `0` (none) | The deadline of every request in milliseconds (an integer from 0 to 2³¹−1, see [Lifecycle](#lifecycle)) | | `logger` | silent unless `BINDEN_LOG_LEVEL` is set | See [Logging](#logging) | | `log_error_details` | `false` | Whether the errors that the framework logs include their message, stack, cause and any `code` (they may quote the input of a request, see [Logging](#logging)) | @@ -980,7 +981,7 @@ response.write("data: 1\n\n"); await response.flush(); ``` -- Dynamic `ETag` - with the `etag` option of the response (`false` by default), a `200` response to `GET` or `HEAD` whose body is a `Buffer` or a string (`.send()`, `.json()`, `.text()`, `.html()`, `.form()`) gets a strong `ETag` (the SHA-256 of the bytes that are sent, in base64url), unless it has an `ETag` already, and becomes `304 Not Modified` (without a body, with the same `ETag`, `Vary` and `Cache-Control`) when `If-None-Match` matches it by the weak comparison. With `http.createServer()`, set the option in a subclass +- Dynamic `ETag` - with the `etag` option of the response (`new Binden({ etag: true })`, `false` by default), a `200` response to `GET` or `HEAD` whose body is a `Buffer` or a string (`.send()`, `.json()`, `.text()`, `.html()`, `.form()`) gets a strong `ETag` (the SHA-256 of the bytes that are sent, in base64url), unless it has an `ETag` already, and becomes `304 Not Modified` (without a body, with the same `ETag`, `Vary` and `Cache-Control`) when `If-None-Match` matches it by the weak comparison. With `http.createServer()`, set the option in a subclass ```typescript import { createServer } from "http"; diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index 8d6adb5d..07b8c3d3 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -14,8 +14,18 @@ import { } from "./logger.js"; import { Middleware, type IMiddleware } from "./middleware.js"; import { pathOf, property } from "./property.js"; -import { BindenRequest, type ITrustProxy } from "./request.js"; -import { BindenResponse, ct_json } from "./response.js"; +import { + BindenRequest, + type IRequestOptions, + type IRequestOptionsInput, + validateRequestOptions, +} from "./request.js"; +import { + BindenResponse, + ct_json, + type IResponseOptions, + validateResponseOptions, +} from "./response.js"; import { matchPrefix, matchRoute, type IRoutingOptions } from "./route.js"; import { Router } from "./router.js"; import { isClosing, register, track } from "./shutdown.js"; @@ -46,10 +56,14 @@ interface IAccessLog { readonly path: "path" | "template"; } -/** The options of `Binden`, but its `state` */ +/** + * The options of `Binden`, but its `state`: with the options of its requests + * (`trust_proxy`, `allowed_hosts`, `request_id`, `encoded_slashes` and + * `query`, see `IRequestOptionsInput`) and of its responses (`etag`) + */ export interface IBindenBaseOptions< State extends object = Record, -> { +> extends IRequestOptionsInput { /** * Whether a router without `HEAD` middlewares handles `HEAD` with its `GET` * middlewares (`true`) @@ -87,11 +101,11 @@ export interface IBindenBaseOptions< */ access_log?: IAccessLogOptions | boolean | undefined; /** - * The proxies whose `Forwarded` elements `request.protocol` trusts: none - * (`false`, the default), all of them (`true`) or the given number of the - * nearest ones + * A strong `ETag` (the SHA-256 of the body) for the `200` responses to `GET` + * and `HEAD` sent from a `Buffer` or a string, and `304 Not Modified` when + * `If-None-Match` matches it (`false`, see `IResponseOptions`) */ - trust_proxy?: ITrustProxy | undefined; + etag?: boolean | undefined; } /** @@ -148,6 +162,7 @@ export class Binden< readonly #routing: Required; readonly #state: (() => object) | null; readonly #Request: typeof BindenRequest; + readonly #Response: typeof BindenResponse; public constructor( ...[options = {} as IBindenOptions]: Record< @@ -168,14 +183,22 @@ export class Binden< routing = {}, state, timeout = 0, - trust_proxy = false, + trust_proxy, + encoded_slashes, + allowed_hosts, + request_id, + query, + etag, } = options; - if ( - typeof trust_proxy === "number" && - !(Number.isSafeInteger(trust_proxy) && trust_proxy >= 0) - ) { - throw new RangeError("`trust_proxy` is not a non-negative integer"); - } + // Checked once: the requests and the responses of the application share them + const requests = validateRequestOptions({ + trust_proxy, + encoded_slashes, + allowed_hosts, + request_id, + query, + }); + const responses = validateResponseOptions({ etag }); validateDelay("timeout", timeout, 0); validateDelay("error_handler_timeout", error_handler_timeout, 1); Binden.#boolean("auto_head", auto_head); @@ -207,11 +230,19 @@ export class Binden< this.#details = log_error_details; this.#routing = { case_sensitive, strict }; this.#state = state ?? null; - // The requests of this application trust its proxies + // The requests and the responses of this application, with its options this.#Request = class extends BindenRequest { - // eslint-disable-next-line class-methods-use-this -- the option of the app - protected override get trust_proxy(): ITrustProxy { - return trust_proxy; + // eslint-disable-next-line class-methods-use-this -- the options of the app + protected override get options(): IRequestOptions { + return requests; + } + }; + this.#Response = class< + Request extends http.IncomingMessage = http.IncomingMessage, + > extends BindenResponse { + // eslint-disable-next-line class-methods-use-this -- the options of the app + protected override get options(): IResponseOptions { + return responses; } }; } @@ -254,7 +285,7 @@ export class Binden< IncomingMessage: typeof BindenRequest; ServerResponse: typeof BindenResponse; } { - return { IncomingMessage: this.#Request, ServerResponse: BindenResponse }; + return { IncomingMessage: this.#Request, ServerResponse: this.#Response }; } /** @@ -276,7 +307,7 @@ export class Binden< return (request, response): void => { if ( !(request instanceof this.#Request) || - !(response instanceof BindenResponse) + !(response instanceof this.#Response) ) { const message = "The request and the response are not those of the application (`serverOptions()`)"; diff --git a/packages/binden/src/request.ts b/packages/binden/src/request.ts index 7a268c64..cbff2359 100644 --- a/packages/binden/src/request.ts +++ b/packages/binden/src/request.ts @@ -89,10 +89,24 @@ export interface IRequestIdOptions { /** The request options as given (to `Binden`), every one of them optional */ export interface IRequestOptionsInput { + /** + * The trusted proxies (none by default): `true` (all of them), a number of + * the nearest hops, a function of the address and the hop, or an object + * with one of them as `trust` and the `headers` that they set (`forwarded` + * by default, or `x-forwarded`) + */ trust_proxy?: ITrustProxy | undefined; + /** + * Whether the path of a request-target (and so a route parameter) may have + * an encoded slash or backslash (`%2F`, `%5C`): `false` by default (a + * `400 Bad Request`) + */ encoded_slashes?: boolean | undefined; + /** The hosts that the requests may have (any by default), see `IRequestOptions` */ allowed_hosts?: IAllowedHosts | undefined; + /** The request ids (new ids, not sent back, by default) */ request_id?: IRequestIdOptions | undefined; + /** The greatest number of keys that `request.query` parses (`1000`) */ query?: { max_keys?: number | undefined } | undefined; } @@ -413,23 +427,14 @@ export class BindenRequest extends IncomingMessage { } /** - * The settings of the requests (see `IRequestOptions`): the defaults, with - * the `trust_proxy` getter. `Binden` sets them from its options; with + * The settings of the requests (see `IRequestOptions`): the defaults. + * `Binden` sets them from its options in a subclass of its own; with * `http.createServer()`, override it in a subclass to return the result of * `validateRequestOptions()`. */ - protected get options(): Readonly { - return { ...defaults, trust_proxy: this.trust_proxy }; - } - - /** - * The trusted proxies (none by default), for the default `options` - * - * @deprecated Override `options` instead - */ // eslint-disable-next-line class-methods-use-this -- overridden by subclasses - protected get trust_proxy(): ITrustProxy { - return false; + protected get options(): Readonly { + return defaults; } /** diff --git a/packages/binden/test/options.test.ts b/packages/binden/test/options.test.ts new file mode 100644 index 00000000..7b7531dc --- /dev/null +++ b/packages/binden/test/options.test.ts @@ -0,0 +1,217 @@ +import { deepStrictEqual, ok, throws } from "node:assert/strict"; +import { once } from "node:events"; +import { connect } from "node:net"; +import { suite, test } from "node:test"; + +import { Binden, type Context, type IBindenOptions } from "../index.js"; +import { + capture, + type ICaptured, + type IListening, + listen, + send, +} from "./helpers.js"; + +/** Runs `fn` with a listening application (with a captured debug logger) */ +async function withApp( + options: IBindenOptions, + setup: (app: Binden) => void, + fn: (listening: IListening, captured: ICaptured) => Promise, +): Promise { + const captured = capture(); + const app = new Binden({ ...options, logger: captured.logger }); + setup(app); + const listening = await listen(app); + try { + await fn(listening, captured); + } finally { + await listening.close(); + } +} + +suite("Request and response options", () => { + test("are validated by the constructor", () => { + const invalid: [IBindenOptions, Error][] = [ + [ + { trust_proxy: -1 }, + new RangeError("`trust_proxy` is not a non-negative integer"), + ], + [ + { trust_proxy: "yes" as unknown as boolean }, + new TypeError("`trust_proxy` is not a boolean, number or function"), + ], + [ + { allowed_hosts: [] }, + new RangeError("`allowed_hosts` is empty: no request is allowed"), + ], + [ + { allowed_hosts: ["a b"] }, + new TypeError("`allowed_hosts` has an invalid host: a b"), + ], + [ + { encoded_slashes: 1 as unknown as boolean }, + new TypeError("`encoded_slashes` is not a boolean"), + ], + [ + { request_id: { echo: "no" as unknown as boolean } }, + new TypeError("`request_id.echo` is not a boolean"), + ], + [ + { query: { max_keys: 0 } }, + new RangeError("`query.max_keys` is not a positive integer"), + ], + [ + { etag: "yes" as unknown as boolean }, + new TypeError("`etag` is not a boolean"), + ], + ]; + for (const [options, error] of invalid) { + throws(() => new Binden(options), error); + } + }); + + test("apply to the requests and the responses of the application", async () => { + await withApp( + { + trust_proxy: { trust: 1, headers: "x-forwarded" }, + allowed_hosts: ["localhost"], + query: { max_keys: 1 }, + etag: true, + }, + (app) => { + app.use((context: Context) => + context.json({ + ip: context.request.ip, + protocol: context.request.protocol, + query: context.request.query, + }), + ); + }, + async ({ port }) => { + const response = await send(port, { + path: "/?a=1&b=2", + headers: { + "X-Forwarded-For": "1.2.3.4", + "X-Forwarded-Proto": "https", + }, + }); + deepStrictEqual(JSON.parse(response.body), { + ip: "1.2.3.4", + protocol: "https:", + query: { a: "1" }, + }); + ok(typeof response.headers.etag === "string"); + // Not an allowed host + const refused = await send(port, { headers: { Host: "evil.test" } }); + deepStrictEqual(refused.status, 400); + }, + ); + }); + + test("are the options of one application only", async () => { + const defaults = new Binden().use((context: Context) => + context.json({ ip: context.request.ip }), + ); + await withApp( + { trust_proxy: true, encoded_slashes: true }, + () => { + // Nothing + }, + async () => { + const listening = await listen(defaults); + try { + const response = await send(listening.port, { + headers: { Forwarded: "for=1.2.3.4" }, + }); + // The peer: the proxies are not trusted + ok((JSON.parse(response.body) as { ip: string }).ip !== "1.2.3.4"); + // No ETag by default + ok(!("etag" in response.headers)); + deepStrictEqual( + (await send(listening.port, { path: "/a%2Fb" })).status, + 400, + ); + } finally { + await listening.close(); + } + }, + ); + }); + + test("an invalid request is refused (400) before the stack runs", async () => { + let reached = false; + await withApp( + { allowed_hosts: ["localhost"], access_log: { path: "path" } }, + (app) => { + app.use(() => { + reached = true; + }); + }, + async ({ port }, captured) => { + const requests = [ + // Dot-segments, and an invalid percent-encoding + { path: "/a/../b" }, + { path: "/%zz" }, + // An encoded slash (without `encoded_slashes`) + { path: "/a%2Fb" }, + // A scheme that is not HTTP, and userinfo + { path: "ftp://localhost/x" }, + { path: "http://user:secret@localhost/x" }, + // An invalid host, and a host that is not allowed + { headers: { Host: "a b" } }, + { headers: { Host: "evil.test" } }, + ]; + for (const options of requests) { + const { status } = await send(port, options); + deepStrictEqual(status, 400, JSON.stringify(options)); + } + // Several `Host` field lines + const socket = connect(port); + await once(socket, "connect"); + socket.end( + "GET / HTTP/1.1\r\nHost: localhost\r\nHost: evil.test\r\nConnection: close\r\n\r\n", + ); + const [chunk] = (await once(socket.setEncoding("utf8"), "data")) as [ + string, + ]; + ok(chunk.startsWith("HTTP/1.1 400"), chunk); + await once(socket, "close"); + deepStrictEqual(reached, false); + // Logged without failing: the access log does not read the URL + const records = captured.records.filter( + ({ msg }) => msg === "The request has completed", + ); + deepStrictEqual(records.length, requests.length + 1); + ok(!captured.output().includes("secret")); + }, + ); + }); + + test("a callback that does not return a boolean is a server error (500)", async () => { + await withApp( + { + allowed_hosts: (() => "yes") as unknown as (host: string) => boolean, + }, + (app) => { + app.use((context: Context) => context.send("ok")); + }, + async ({ port }, captured) => { + deepStrictEqual((await send(port)).status, 500); + const record = await captured.waitFor("The request has failed"); + deepStrictEqual( + [record.level, (record.failure as { type: string }).type], + [50, "TypeError"], + ); + }, + ); + await withApp( + { trust_proxy: (() => 1) as unknown as () => boolean }, + (app) => { + app.use((context: Context) => context.send("ok")); + }, + async ({ port }) => { + deepStrictEqual((await send(port)).status, 500); + }, + ); + }); +}); diff --git a/packages/binden/test/request.test.ts b/packages/binden/test/request.test.ts index 59979ec3..d8631462 100644 --- a/packages/binden/test/request.test.ts +++ b/packages/binden/test/request.test.ts @@ -478,22 +478,6 @@ suite("BindenRequest", () => { deepStrictEqual(request.query, { a: "1", b: "2" }); }); - test(".trust_proxy (deprecated)", () => { - class Trusting extends BindenRequest { - // eslint-disable-next-line class-methods-use-this -- a fixture - protected override get trust_proxy(): ITrustProxy { - return 1; - } - } - const socket = new Socket(); - Object.defineProperty(socket, "remoteAddress", { value: "10.0.0.1" }); - const request = new Trusting(socket); - request.headers = { forwarded: "for=1.1.1.1;proto=https" }; - // The default `options` take it - deepStrictEqual([request.ip, request.protocol], ["1.1.1.1", "https:"]); - socket.destroy(); - }); - test(".query (keeps its prototype)", () => { const request = new BindenRequest(new Socket()); request.url = "/?__proto__=a&__proto__=b&x=1"; diff --git a/packages/binden/test/shutdown.test.ts b/packages/binden/test/shutdown.test.ts index d4282585..2e9be666 100644 --- a/packages/binden/test/shutdown.test.ts +++ b/packages/binden/test/shutdown.test.ts @@ -311,10 +311,12 @@ suite("callback()", () => { context.send(String(context.request instanceof BindenRequest)), ); const options = app.serverOptions(); - ok(options.ServerResponse === BindenResponse); - ok( - new Binden().serverOptions().IncomingMessage !== options.IncomingMessage, - ); + // The classes of the application, with its options + ok(options.IncomingMessage.prototype instanceof BindenRequest); + ok(options.ServerResponse.prototype instanceof BindenResponse); + const other = new Binden().serverOptions(); + ok(other.IncomingMessage !== options.IncomingMessage); + ok(other.ServerResponse !== options.ServerResponse); const server = createServer(options); server.on("request", app.callback(server)); server.listen(0); @@ -342,8 +344,11 @@ suite("callback()", () => { for (const options of [ {}, { IncomingMessage: other.IncomingMessage }, - // The response of the application, the request of another one - { ...other, ServerResponse: BindenResponse }, + // The request of another application + { ...app.serverOptions(), IncomingMessage: other.IncomingMessage }, + // The response of another application + { ...app.serverOptions(), ServerResponse: other.ServerResponse }, + { ...app.serverOptions(), ServerResponse: BindenResponse }, ]) { const server = createServer(options); server.on("request", app.callback(server)); From 02838812c0f52272f54a50e485b849430e9d4e43 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 01:12:34 +0300 Subject: [PATCH 148/675] feat: send the request id back in binden With `request_id.echo` (the default once `request_id` is set), the client and the proxies in front of the server should get the id of the request in the response, so that their logs can be joined with those of the application. Nothing sent it. The id is now set on the response as soon as the request arrives, in the field of `request_id.header`: every reply has it, also the error replies of the framework (404, the deadline 503, the opaque 500 of a failed error handler, and the 503 of a closing server). Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 2 +- packages/binden/src/binden.ts | 6 ++ packages/binden/test/options.test.ts | 111 ++++++++++++++++++++++++++- 3 files changed, 117 insertions(+), 2 deletions(-) diff --git a/packages/binden/README.md b/packages/binden/README.md index fabf2dc8..e1c4ee40 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -821,7 +821,7 @@ const api = new Binden({ - `header` (default `"x-request-id"`) - the field of the id - `trust_incoming` (default `false`) - whether the id of the request is used: only when the proxies in front of the server set (or check) it, since a client can send any id. The id is used when it is one field line of 1 to 128 letters, digits, `.`, `_`, `:` and `-`; otherwise the request gets a new UUID -- `echo` (default `true`) - whether the response sends the id back (in the field `header`) +- `echo` (default `true`) - whether every response of a `Binden` application sends the id back (in the field `header`), also the error replies of the framework (the deadline, a closing server, a failed error handler). It is set when the request arrives: a middleware may remove it. ```typescript import { Binden } from "binden"; diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index 07b8c3d3..fe8e5457 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -163,6 +163,7 @@ export class Binden< readonly #state: (() => object) | null; readonly #Request: typeof BindenRequest; readonly #Response: typeof BindenResponse; + readonly #request_id: IRequestOptions["request_id"]; public constructor( ...[options = {} as IBindenOptions]: Record< @@ -230,6 +231,7 @@ export class Binden< this.#details = log_error_details; this.#routing = { case_sensitive, strict }; this.#state = state ?? null; + this.#request_id = requests.request_id; // The requests and the responses of this application, with its options this.#Request = class extends BindenRequest { // eslint-disable-next-line class-methods-use-this -- the options of the app @@ -340,6 +342,10 @@ export class Binden< }); const exchange = Exchange.for(request, response); track(server, exchange, response); + if (this.#request_id?.echo === true) { + // Every reply sends it back, also the error replies of the framework + response.setHeader(this.#request_id.header, request.id); + } if (this.#access) { Binden.#accessLog(context, exchange, this.#access); } diff --git a/packages/binden/test/options.test.ts b/packages/binden/test/options.test.ts index 7b7531dc..266697fc 100644 --- a/packages/binden/test/options.test.ts +++ b/packages/binden/test/options.test.ts @@ -3,13 +3,20 @@ import { once } from "node:events"; import { connect } from "node:net"; import { suite, test } from "node:test"; -import { Binden, type Context, type IBindenOptions } from "../index.js"; +import { + Binden, + type Context, + type IBindenOptions, + shutdown, +} from "../index.js"; import { capture, type ICaptured, type IListening, listen, + never, send, + sleep, } from "./helpers.js"; /** Runs `fn` with a listening application (with a captured debug logger) */ @@ -215,3 +222,105 @@ suite("Request and response options", () => { ); }); }); + +suite("Request ids", () => { + test("are sent back by every reply with `request_id.echo`", async () => { + const release = Promise.withResolvers(); + await withApp( + { request_id: {}, timeout: 30 }, + (app) => { + app.use("/ok", (context: Context) => context.send(context.id)); + app.use("/hangs", async () => { + await never(); + }); + app.use("/slow", async (context: Context) => { + context.setTimeout(0); + // The headers keep the connection alive + context.response.writeHead(200, { "Content-Length": 1 }); + context.response.flushHeaders(); + await release.promise; + context.response.end("a"); + context.done = true; + }); + }, + async ({ port, server }) => { + const ok200 = await send(port, { path: "/ok" }); + deepStrictEqual(ok200.headers["x-request-id"], ok200.body); + // An error reply and the deadline + const ids: unknown[] = [ + (await send(port, { path: "/missing" })).headers["x-request-id"], + (await send(port, { path: "/hangs" })).headers["x-request-id"], + ]; + // A request that arrives on an open connection once closing + const client = connect(port); + await once(client, "connect"); + let received = ""; + client.setEncoding("utf8").on("data", (chunk: string) => { + received += chunk; + }); + client.write("GET /slow HTTP/1.1\r\nHost: localhost\r\n\r\n"); + while (!received.includes("Content-Length: 1")) { + await sleep(5); + } + const closing = shutdown(server, { timeout: 1000 }); + client.write("GET /ok HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await sleep(20); + release.resolve(null); + await closing; + await once(client, "close"); + const [, second = ""] = received.split(/(?=HTTP\/1\.1 )/u); + ok(second.startsWith("HTTP/1.1 503"), received); + ids.push(/\r\nx-request-id: (?[^\r]+)/iu.exec(second)?.groups?.id); + for (const id of ids) { + ok(typeof id === "string" && /^[\da-f-]{36}$/u.test(id), String(id)); + } + }, + ); + }); + + test("an incoming id, a header name, and no echo", async () => { + await withApp( + { request_id: { header: "X-Trace", trust_incoming: true } }, + (app) => { + app.use((context: Context) => context.send()); + }, + async ({ port }) => { + const { headers } = await send(port, { + headers: { "X-Trace": "abc.1" }, + }); + deepStrictEqual(headers["x-trace"], "abc.1"); + ok(!("x-request-id" in headers)); + }, + ); + await withApp( + { request_id: { echo: false } }, + (app) => { + app.use((context: Context) => context.send()); + }, + async ({ port }) => { + ok(!("x-request-id" in (await send(port)).headers)); + }, + ); + }); + + test("the opaque reply of a failed error handler sends it back", async () => { + await withApp( + { + request_id: {}, + error_handler: (): never => { + throw new Error("A failing error handler"); + }, + }, + (app) => { + app.use(() => { + throw new Error("A failure"); + }); + }, + async ({ port }) => { + const response = await send(port); + deepStrictEqual(response.status, 500); + ok(typeof response.headers["x-request-id"] === "string"); + }, + ); + }); +}); From 9b121d888c1d3e312720b6137c1decaf8551f03d Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 01:14:26 +0300 Subject: [PATCH 149/675] feat: decode encoded slashes in route parameters in binden A parameter of `route()` or `mount()` with an encoded slash or backslash was always a 400, even when the `encoded_slashes` option allows such a path, so an application that opts in (e.g. for keys that contain slashes) could not route it. With `encoded_slashes: true`, a parameter (and a segment of a `*name` tail) now gets the decoded `/` or `\` in its value, while the segments of the path stay those that the client has sent. Without it, the request is still refused with a 400, and the matching refuses such a parameter on its own as well. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 4 +-- packages/binden/src/binden.ts | 15 ++++++-- packages/binden/src/route.ts | 45 ++++++++++++++++++------ packages/binden/test/routing.test.ts | 52 +++++++++++++++++++++++++++- 4 files changed, 100 insertions(+), 16 deletions(-) diff --git a/packages/binden/README.md b/packages/binden/README.md index e1c4ee40..7ea77803 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -99,7 +99,7 @@ app.use("/path2", middleware3); - `.route()` - add middlewares and routers for the paths that a pattern matches, with its parameters in `context.params` (typed from the pattern): - `:name` matches one non-empty segment, and `*name` (the last segment only) the rest of the path (zero or more segments); - a name is `[A-Za-z_][A-Za-z0-9_]*`, and `\:` and `\*` are a literal `:` and `*`; - - the path is split into its segments first, then every parameter (and every segment of `*name`) is decoded once: a parameter that is not valid percent-encoded UTF-8, or that has an encoded `/` or `\` (`%2F`, `%5C`), is `400 Bad Request`. Literal segments are compared once decoded (write them decoded); + - the path is split into its segments first, then every parameter (and every segment of `*name`) is decoded once: a parameter that is not valid percent-encoded UTF-8 is `400 Bad Request`, and so is one with an encoded `/` or `\` (`%2F`, `%5C`) unless the `encoded_slashes` option is `true` (see [Encoded slashes](#encoded-slashes)). Literal segments are compared once decoded (write them decoded); - a pattern without a leading `/`, with an empty or duplicate name, a `*name` that is not last, or a `:` or `*` inside a segment (unless escaped) throws a `TypeError`. ```typescript @@ -832,7 +832,7 @@ const app = new Binden({ request_id: { trust_incoming: true } }); #### Encoded slashes -A `%2F` (or `%5C`) in a path is a slash (or a backslash) once decoded: a router would see one segment where a proxy or a file server sees several, or the opposite. A request whose path has one (in any case) gets a 400, unless `encoded_slashes` is `true`. The query is not checked, and `%252F` is fine: once decoded, it is the text `%2F`. +A `%2F` (or `%5C`) in a path is a slash (or a backslash) once decoded: a router would see one segment where a proxy or a file server sees several, or the opposite. A request whose path has one (in any case) gets a 400, unless `encoded_slashes` is `true`: then a parameter of [`route()` or `mount()`](#stack-and-routing) has the decoded `/` (or `\`) in its value (e.g. `name` is `a/b` for `/files/:name` and `/files/a%2Fb`), while the segments of the path stay those that the client has sent. The query is not checked, and `%252F` is fine: once decoded, it is the text `%2F`. ```typescript import { Binden } from "binden"; diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index fe8e5457..bbcd3689 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -26,7 +26,12 @@ import { type IResponseOptions, validateResponseOptions, } from "./response.js"; -import { matchPrefix, matchRoute, type IRoutingOptions } from "./route.js"; +import { + type IMatchOptions, + matchPrefix, + matchRoute, + type IRoutingOptions, +} from "./route.js"; import { Router } from "./router.js"; import { isClosing, register, track } from "./shutdown.js"; import { type ILayer, layersOf, markRoot, Stack } from "./stack.js"; @@ -159,7 +164,7 @@ export class Binden< readonly #logger: ILogger; readonly #details: boolean; readonly #access: IAccessLog | null; - readonly #routing: Required; + readonly #routing: Required; readonly #state: (() => object) | null; readonly #Request: typeof BindenRequest; readonly #Response: typeof BindenResponse; @@ -229,7 +234,11 @@ export class Binden< this.#auto_head = auto_head; this.#logger = safe(logger ?? defaultLogger()); this.#details = log_error_details; - this.#routing = { case_sensitive, strict }; + this.#routing = { + case_sensitive, + strict, + encoded_slashes: requests.encoded_slashes, + }; this.#state = state ?? null; this.#request_id = requests.request_id; // The requests and the responses of this application, with its options diff --git a/packages/binden/src/route.ts b/packages/binden/src/route.ts index 2976f592..8b73bf22 100644 --- a/packages/binden/src/route.ts +++ b/packages/binden/src/route.ts @@ -141,11 +141,14 @@ function decode(segment: string): string | null { } } -/** The value of a parameter: `400 Bad Request` when it cannot be decoded */ -function value(segment: string): string { +/** + * The value of a parameter: `400 Bad Request` when it cannot be decoded, or + * when it has an encoded `/` (or `\`), which would change the segments of the + * path, unless `encoded_slashes` allows them + */ +function value(segment: string, encoded_slashes: boolean): string { const decoded = decode(segment); - // An encoded `/` (or `\`) would change the segments of the path - if (decoded === null || /[/\\]/u.test(decoded)) { + if (decoded === null || (!encoded_slashes && /[/\\]/u.test(decoded))) { throw new BindenError(400); } return decoded; @@ -178,6 +181,7 @@ function leading( segments: readonly ISegment[], path: readonly string[], case_sensitive: boolean, + encoded_slashes: boolean, ): IParams | null { for (const [index, segment] of segments.entries()) { const part = path[index]; @@ -192,7 +196,7 @@ function leading( const params: [string, string][] = []; for (const [index, segment] of segments.entries()) { if ("param" in segment) { - params.push([segment.param, value(path[index])]); + params.push([segment.param, value(path[index], encoded_slashes)]); } } // Defined, not assigned: a parameter may be named `__proto__` @@ -204,11 +208,24 @@ function split(path: string): string[] { return path.slice(1).split("/"); } +/** + * The options of the matching: those of the routing, and whether a + * parameter may have an encoded slash (see the `encoded_slashes` request + * option) + */ +export interface IMatchOptions extends IRoutingOptions { + readonly encoded_slashes?: boolean | undefined; +} + /** Matches a whole path: the parameters of a route, or `null` */ export function matchRoute( pattern: IPattern, path: string, - { case_sensitive = true, strict = true }: IRoutingOptions, + { + case_sensitive = true, + strict = true, + encoded_slashes = false, + }: IMatchOptions, ): IParams | null { let parts = split(path); // One trailing `/` of the path, or of the pattern, may be missing @@ -223,10 +240,13 @@ export function matchRoute( ) { return null; } - const params = leading(segments, parts, case_sensitive); + const params = leading(segments, parts, case_sensitive, encoded_slashes); if (params && pattern.wildcard !== null) { // The rest of the path, each segment decoded once - const rest = parts.slice(segments.length).map(value).join("/"); + const rest = parts + .slice(segments.length) + .map((part) => value(part, encoded_slashes)) + .join("/"); return { ...params, ...Object.fromEntries([[pattern.wildcard, rest]]) }; } return params; @@ -239,14 +259,19 @@ export function matchRoute( export function matchPrefix( pattern: IPattern, path: string, - { case_sensitive = true }: IRoutingOptions, + { case_sensitive = true, encoded_slashes = false }: IMatchOptions, ): IPrefixMatch | null { const parts = split(path); const { length } = pattern.segments; if (parts.length < length) { return null; } - const params = leading(pattern.segments, parts, case_sensitive); + const params = leading( + pattern.segments, + parts, + case_sensitive, + encoded_slashes, + ); if (!params) { return null; } diff --git a/packages/binden/test/routing.test.ts b/packages/binden/test/routing.test.ts index 7354d86a..00d51010 100644 --- a/packages/binden/test/routing.test.ts +++ b/packages/binden/test/routing.test.ts @@ -3,13 +3,14 @@ import { suite, test } from "node:test"; import { Binden, + BindenError, Context, type IBindenOptions, Middleware, Router, Stack, } from "../index.js"; -import { compile } from "../src/route.js"; +import { compile, matchPrefix, matchRoute } from "../src/route.js"; import { layersOf } from "../src/stack.js"; import { type IApp, type IListening, listen, send } from "./helpers.js"; @@ -224,6 +225,55 @@ suite("Routing", () => { }); }); + test("an encoded slash in a parameter with `encoded_slashes`", async () => { + const app = new Binden({ encoded_slashes: true }) + .route("/files/:name", echo("file")) + .route("/raw/*rest", echo("raw")) + .mount("/users/:id", echo("user")); + await withApp(app, async ({ port }) => { + const params = async (path: string): Promise => + ((await get(port, path))[1] as { params: unknown }).params; + deepStrictEqual(await params("/files/a%2Fb"), { name: "a/b" }); + deepStrictEqual(await params("/files/a%5cb"), { name: "a\\b" }); + deepStrictEqual(await params("/raw/x/a%2Fb"), { rest: "x/a/b" }); + deepStrictEqual(await params("/users/a%2Fb/x"), { id: "a/b" }); + }); + // Without it: the whole path is refused + const strict = new Binden().route("/files/:name", echo("file")); + await withApp(strict, async ({ port }) => { + deepStrictEqual((await get(port, "/files/a%2Fb"))[0], 400); + }); + }); + + test("matchRoute() and matchPrefix() refuse an encoded slash without `encoded_slashes`", () => { + const route = compile("/:name/*rest", "route"); + const prefix = compile("/:name", "mount"); + for (const path of ["/a%2Fb/c", "/a/b%5Cc"]) { + throws( + () => matchRoute(route, path, {}), + (error: unknown) => + error instanceof BindenError && error.status === 400, + ); + } + throws( + () => matchPrefix(prefix, "/a%2Fb/c", {}), + (error: unknown) => + error instanceof BindenError && error.status === 400, + ); + deepStrictEqual( + matchRoute(route, "/a%2Fb/c%5Cd", { encoded_slashes: true }), + { name: "a/b", rest: "c\\d" }, + ); + deepStrictEqual( + matchPrefix(prefix, "/a%2Fb/c", { encoded_slashes: true }), + { + params: { name: "a/b" }, + prefix: "/a%2Fb", + rest: "/c", + }, + ); + }); + test("`routing.case_sensitive: false` and `routing.strict: false`", async () => { const app = new Binden({ routing: { case_sensitive: false, strict: false }, From 0292b574a5890fcd293286dbc2b6508d76d49a6e Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 01:16:54 +0300 Subject: [PATCH 150/675] fix: seal the response along with the exchange in binden Once the deadline had passed (or an error handler had failed), the pipeline sealed the exchange, which made the replies through the context do nothing, but a middleware that called the response itself (e.g. `context.response.send()` or `redirect()`) could still reply before the error handler and replace its reply. The response of every request is now sealed with the state of the exchange. Once the exchange is sealed, `send()`, `json()`, `text()`, `html()`, `form()`, `redirect()`, `sendFile()` and `flush()` resolve without writing anything, except for the work of the reply of the framework (run by `exchange.reply()`). The methods of Node.js stay unguarded, as documented. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 2 +- packages/binden/src/binden.ts | 5 +++ packages/binden/test/lifecycle.test.ts | 42 ++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/binden/README.md b/packages/binden/README.md index 7ea77803..329705e0 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -215,7 +215,7 @@ Every request has an abort signal, `context.signal`, aborted at most once (its ` The deadline of a request is the `timeout` option of `Binden` (none by default). A middleware restarts it from now with `context.setTimeout(ms)`, or removes it with `context.setTimeout(0)` (e.g. for a stream of server-sent events). Once the deadline has passed: -- the stack stops: no other middleware runs, and the result of the running one is ignored (a later failure is logged at `debug`, and its later replies through the context, such as `context.send()`, do nothing); +- the stack stops: no other middleware runs, and the result of the running one is ignored (a later failure is logged at `debug`). Its later replies through the context or the response (`send()`, `json()`, `text()`, `html()`, `form()`, `redirect()`, `sendFile()` and `flush()`) do nothing, so that they cannot replace the reply of the error handler. The methods of Node.js (`write()`, `end()`, `writeHead()`, `flushHeaders()`, or a stream piped into the response) are not guarded: a middleware that writes with them must stop once `context.signal` is aborted; - a request whose reply has not started gets a `BindenError(503, { code: "deadline" })`, which goes to the error handler; - a reply that has started (or a response that has not finished once the stack has completed) is cut by closing the connection. diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index bbcd3689..59c98aac 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -33,6 +33,7 @@ import { type IRoutingOptions, } from "./route.js"; import { Router } from "./router.js"; +import { kSeal } from "./seal.js"; import { isClosing, register, track } from "./shutdown.js"; import { type ILayer, layersOf, markRoot, Stack } from "./stack.js"; @@ -351,6 +352,10 @@ export class Binden< }); const exchange = Exchange.for(request, response); track(server, exchange, response); + // Once the exchange is sealed (e.g. after the deadline), the sends of + // the response (`send()`, `json()`, `redirect()`, `sendFile()`, …) do + // nothing, but those of the reply of the framework + response[kSeal](() => !exchange.may_reply); if (this.#request_id?.echo === true) { // Every reply sends it back, also the error replies of the framework response.setHeader(this.#request_id.header, request.id); diff --git a/packages/binden/test/lifecycle.test.ts b/packages/binden/test/lifecycle.test.ts index 8394c467..68bd9464 100644 --- a/packages/binden/test/lifecycle.test.ts +++ b/packages/binden/test/lifecycle.test.ts @@ -386,3 +386,45 @@ suite("Lifecycle", () => { }); }); }); + +suite("Lifecycle (late sends of the response)", () => { + const late: Partial Promise>> = { + "/send": (context) => context.response.send("late"), + "/json": (context) => context.response.json({ late: true }), + "/text": (context) => context.response.text("late"), + "/redirect": (context) => context.response.redirect("/elsewhere"), + "/sendFile": (context) => context.response.sendFile(import.meta.filename), + // It would send the headers + "/flush": (context) => context.response.flush(), + }; + + test("a send of the response after the deadline does not replace the reply of the error handler", async () => { + await withApp( + { + timeout: 20, + error_handler: async (context: Context): Promise => { + // The late send comes first + await sleep(80); + await context.status(503).send("handler"); + }, + }, + (app) => { + app.use(async (context: Context) => { + await sleep(40); + await late[context.url.pathname]?.(context); + }); + }, + async ({ port }) => { + for (const path of Object.keys(late)) { + const response = await send(port, { path }); + deepStrictEqual( + [response.status, response.body], + [503, "handler"], + path, + ); + ok(!("location" in response.headers), path); + } + }, + ); + }); +}); From 96ed1fb6e68367f89fa3af9099211baac71513b5 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 01:20:17 +0300 Subject: [PATCH 151/675] feat: add the response helpers to the context in binden The context mirrored only the first sending methods of the response: a middleware had to call `context.response.redirect()` or pass options to `context.response.sendFile()` itself, and then set `done` by hand, and `context.json()` refused a JSON value that is not an object or an array. The context now has `redirect(location, status?, options?)` and `sendFile(path, options?)`, and `json()` takes any value with the typed `stringify` of `response.json()`; like the other sending methods, they set `done` and ignore a late reply. `attachment()`, `type()`, `vary()` and `clearCookie()` set their headers on the response and return the context. Co-Authored-By: Claude Opus 5.5 --- packages/binden/README.md | 26 ++++++- packages/binden/src/context.ts | 71 +++++++++++++++-- packages/binden/test/context-helpers.test.ts | 82 ++++++++++++++++++++ 3 files changed, 168 insertions(+), 11 deletions(-) create mode 100644 packages/binden/test/context-helpers.test.ts diff --git a/packages/binden/README.md b/packages/binden/README.md index 329705e0..533d0439 100644 --- a/packages/binden/README.md +++ b/packages/binden/README.md @@ -365,7 +365,7 @@ await context.response.send(data); context.done = true; ``` -- `.json()` - execute `context.response.json()` and set `context.done` to `true` +- `.json()` - execute `context.response.json()` (any JSON value) and set `context.done` to `true` ```typescript const json = { message: "Hello World!" }; @@ -430,16 +430,34 @@ await context.response.form(form); context.done = true; ``` -- `.sendFile()` - execute `context.response.sendFile()` and set `context.done` to `true` +- `.sendFile()` - execute `context.response.sendFile()` (with its options, see [BindenResponse](#bindenresponse)) and set `context.done` to `true` ```typescript const path = ""; -await context.sendFile(path); +await context.sendFile(path, { max_age: 3600 }); // or -await context.response.sendFile(path); +await context.response.sendFile(path, { max_age: 3600 }); context.done = true; ``` +- `.redirect()` - execute `context.response.redirect()` (`302 Found` by default, to the same origin unless `allow_external`) and set `context.done` to `true` + +```typescript +await context.redirect("/login", 303); +await context.redirect("https://example.com/", 302, { allow_external: true }); +``` + +- `.attachment()`, `.type()`, `.vary()` and `.clearCookie()` - execute those of `context.response` (they set headers, and return the context: `done` is set by the method that sends) + +```typescript +await context + .attachment("report.csv") + .type("text/csv") + .vary("Accept-Language") + .clearCookie("session", { path: "/" }) + .send("a,b\n1,2\n"); +``` + - `.throw()` - throw `BindenError` ```typescript diff --git a/packages/binden/src/context.ts b/packages/binden/src/context.ts index b2bac752..9385a3b5 100644 --- a/packages/binden/src/context.ts +++ b/packages/binden/src/context.ts @@ -4,8 +4,14 @@ import { Exchange, type IExchangeReport } from "./exchange.js"; import { defaultLogger, safe, snapshot } from "./logger.js"; import type { Readable } from "node:stream"; +import type { IRedirectOptions, IRedirectStatus } from "./redirect.js"; import type { BindenRequest } from "./request.js"; -import type { IHeadersValue, BindenResponse } from "./response.js"; +import type { + IClearCookie, + IHeadersValue, + BindenResponse, +} from "./response.js"; +import type { ISendFileOptions } from "./send-file.js"; export interface IBaseContext { readonly request: BindenRequest; @@ -152,9 +158,13 @@ export class Context< await this.#reply(() => this.response.send(data)); } - public async json( - data: Record | unknown[], - stringify?: (input: Record | unknown[]) => string, + /** + * Execute `response.json()` (a JSON value, with an optional `stringify`) + * and set `done` + */ + public async json( + data: T, + stringify?: (data: T) => string, ): Promise { await this.#reply(async () => { await (typeof stringify === "undefined" @@ -175,9 +185,56 @@ export class Context< await this.#reply(() => this.response.form(data)); } - /** Send a file */ - public async sendFile(url: URL | string): Promise { - await this.#reply(() => this.response.sendFile(url)); + /** Execute `response.sendFile()` (see `ISendFileOptions`) and set `done` */ + public async sendFile( + path: URL | string, + options?: ISendFileOptions, + ): Promise { + await this.#reply(async () => { + await (typeof options === "undefined" + ? this.response.sendFile(path) + : this.response.sendFile(path, options)); + }); + } + + /** + * Execute `response.redirect()` (`302 Found` by default, to the same + * origin unless `allow_external`) and set `done` + */ + public async redirect( + location: URL | string, + status?: IRedirectStatus, + options?: IRedirectOptions, + ): Promise { + await this.#reply(async () => { + await (typeof options === "undefined" + ? this.response.redirect(location, status) + : this.response.redirect(location, status, options)); + }); + } + + /** Execute `response.attachment()`: the response is saved as a file */ + public attachment(filename?: string): this { + this.response.attachment(filename); + return this; + } + + /** Execute `response.type()`: set the `Content-Type` */ + public type(media_type: string): this { + this.response.type(media_type); + return this; + } + + /** Execute `response.vary()`: add request headers to `Vary` */ + public vary(...fields: string[]): this { + this.response.vary(...fields); + return this; + } + + /** Execute `response.clearCookie()`: remove a cookie from the client */ + public clearCookie(name: string, attributes?: IClearCookie): this { + this.response.clearCookie(name, attributes); + return this; } // eslint-disable-next-line class-methods-use-this diff --git a/packages/binden/test/context-helpers.test.ts b/packages/binden/test/context-helpers.test.ts new file mode 100644 index 00000000..2ccab989 --- /dev/null +++ b/packages/binden/test/context-helpers.test.ts @@ -0,0 +1,82 @@ +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; + +import { Binden, type Context } from "../index.js"; +import { listen, send } from "./helpers.js"; + +suite("Context (response helpers)", () => { + test("redirect(), sendFile() and json() send the response and set `done`", async () => { + const reached: string[] = []; + const app = new Binden() + .use("/redirect", (context: Context) => context.redirect("/x?y=1")) + .use("/moved", (context: Context) => + context.redirect(new URL("https://example.com/"), 301, { + allow_external: true, + }), + ) + .use("/file", (context: Context) => + context.sendFile(import.meta.filename, { content_type: "text/x-test" }), + ) + .use("/json", (context: Context) => + context.json(["a", 1], (value: (number | string)[]): string => + JSON.stringify({ value }), + ), + ) + .use("/string", (context: Context) => context.json("text")) + .use((context: Context) => { + reached.push(context.url.pathname); + }); + const listening = await listen(app); + const { port } = listening; + try { + const redirect = await send(port, { path: "/redirect" }); + deepStrictEqual( + [redirect.status, redirect.headers.location, redirect.body], + [302, "/x?y=1", ""], + ); + const moved = await send(port, { path: "/moved" }); + deepStrictEqual( + [moved.status, moved.headers.location], + [301, "https://example.com/"], + ); + const file = await send(port, { path: "/file" }); + deepStrictEqual(file.headers["content-type"], "text/x-test"); + ok(file.body.includes("Context (response helpers)")); + deepStrictEqual( + (await send(port, { path: "/json" })).body, + '{"value":["a",1]}', + ); + deepStrictEqual((await send(port, { path: "/string" })).body, '"text"'); + deepStrictEqual(reached, []); + } finally { + await listening.close(); + } + }); + + test("attachment(), type(), vary() and clearCookie() set the headers", async () => { + const app = new Binden().use((context: Context) => + context + .attachment("é.txt") + .type("text/plain; charset=utf-8") + .vary("Accept", "accept-language") + .clearCookie("session", { path: "/" }) + .send("x"), + ); + const listening = await listen(app); + const { port } = listening; + try { + const { headers } = await send(port); + deepStrictEqual( + [headers["content-disposition"], headers["content-type"], headers.vary], + [ + `attachment; filename="_.txt"; filename*=UTF-8''%C3%A9.txt`, + "text/plain; charset=utf-8", + "Accept, accept-language", + ], + ); + ok(String(headers["set-cookie"]).startsWith("session=; ")); + } finally { + await listening.close(); + } + }); +}); From 20ee3ee61dbb4acc6505de7368324c50f36dab8e Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 01:20:50 +0300 Subject: [PATCH 152/675] build: list the new modules of binden in the packed files The modules added to binden in this round (the request headers, the proxy, the redirect, the file sending, the seal, the routing, the stack, the shutdown and the delays) were missing from `scripts/pack-files.json`, so `npm run pack:check` refused the archive of binden. The list is regenerated with `node scripts/pack-check.mjs --update`. Co-Authored-By: Claude Opus 5.5 --- scripts/pack-files.json | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/scripts/pack-files.json b/scripts/pack-files.json index 77ef40a7..e8596cb8 100644 --- a/scripts/pack-files.json +++ b/scripts/pack-files.json @@ -15,12 +15,20 @@ "dist/src/binden.js", "dist/src/context.d.ts", "dist/src/context.js", + "dist/src/delay.d.ts", + "dist/src/delay.js", "dist/src/error.d.ts", "dist/src/error.js", "dist/src/exchange.d.ts", "dist/src/exchange.js", + "dist/src/headers/accept-charset.d.ts", + "dist/src/headers/accept-charset.js", "dist/src/headers/accept-encoding.d.ts", "dist/src/headers/accept-encoding.js", + "dist/src/headers/accept-language.d.ts", + "dist/src/headers/accept-language.js", + "dist/src/headers/accept.d.ts", + "dist/src/headers/accept.js", "dist/src/headers/authorization.d.ts", "dist/src/headers/authorization.js", "dist/src/headers/content-encoding.d.ts", @@ -45,14 +53,30 @@ "dist/src/logger.js", "dist/src/middleware.d.ts", "dist/src/middleware.js", + "dist/src/mime.d.ts", + "dist/src/mime.js", "dist/src/property.d.ts", "dist/src/property.js", + "dist/src/proxy.d.ts", + "dist/src/proxy.js", + "dist/src/redirect.d.ts", + "dist/src/redirect.js", "dist/src/request.d.ts", "dist/src/request.js", "dist/src/response.d.ts", "dist/src/response.js", + "dist/src/route.d.ts", + "dist/src/route.js", "dist/src/router.d.ts", "dist/src/router.js", + "dist/src/seal.d.ts", + "dist/src/seal.js", + "dist/src/send-file.d.ts", + "dist/src/send-file.js", + "dist/src/shutdown.d.ts", + "dist/src/shutdown.js", + "dist/src/stack.d.ts", + "dist/src/stack.js", "dist/src/status.d.ts", "dist/src/status.js", "package.json" From e2d333744e5f1bf960514c53b630f5e947caac9e Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 01:52:40 +0300 Subject: [PATCH 153/675] feat!: select, limit and decode the request bodies safely in body BodyParser answered 415 to any body that it could not parse, knew three content types only, and counted only the output of the last decoder, so that a nested coding could still expand without bound in an intermediate stage. `type` selects the bodies to parse: a list of media types, or a function of the Content-Type that returns their kind (json, text, form or raw). JSON and any +json type, text/plain and forms are parsed by default, and application/octet-stream as a Buffer with `raw`. A body that is not selected is skipped without being read, so that BodyParser and Multipart can share a stack, and a body is parsed once per request, which the request tracks (not the parsed value). `limit` counts the output of every decoder, the intermediate ones too, and `encoded_limit` counts the body as received; a larger Content-Length is refused before the body is read. More than two codings, or an unsupported coding or charset, get 415 before any decoder is created. A refusal pauses the request (not destroyed, so that the reply is sent), destroys the decoders and closes the connection after the reply. `parameter_limit` (1000) bounds the parameters of a form (413). The default parse is strict (an object or an array). An empty body is undefined (JSON), "" (text), an empty URLSearchParams (form) or an empty Buffer (raw), and bytes that are not valid in their charset are a 400. `parse` may be asynchronous, and gives its type to the parser; it is awaited against context.signal, so that a parse that never settles is abandoned and its late rejection observed. A client that goes away is a 400 with the code "aborted", and a deadline rejects with the reason of the signal. The options are validated, and the body itself is never logged. BREAKING CHANGE: a body that cannot be parsed or decoded (invalid JSON, `undefined` from `parse`, corrupt compressed data) gets 400 instead of 415, and bytes that are not valid in the charset are a 400 instead of being replaced. Any other failure of a custom `parse` is a 500: reject a body by returning `undefined` or by throwing a BindenError. The default parse accepts only an object or an array (`strict: false` accepts any JSON value), an empty JSON body is undefined (instead of 415), whitespace only is a 400, more than two content codings are a 415, +json types are parsed, and a request destroyed before it is read is a 400. The IBodyContentType type is removed. Co-Authored-By: Claude Opus 5.5 --- packages/body/README.md | 186 +++- packages/body/index.ts | 684 +++++++++++--- packages/body/test/abort.test.ts | 305 ++++-- packages/body/test/codings.test.ts | 133 +++ .../body/test/fixtures/late-rejections.ts | 69 ++ packages/body/test/helpers.ts | 183 ++++ packages/body/test/index.test.ts | 867 ++++++++++-------- packages/body/test/limits.test.ts | 241 +++++ packages/body/test/logging.test.ts | 115 +-- packages/body/test/options.test.ts | 120 +++ packages/body/test/process.test.ts | 28 + packages/body/test/types.test.ts | 83 ++ 12 files changed, 2376 insertions(+), 638 deletions(-) create mode 100644 packages/body/test/codings.test.ts create mode 100644 packages/body/test/fixtures/late-rejections.ts create mode 100644 packages/body/test/helpers.ts create mode 100644 packages/body/test/limits.test.ts create mode 100644 packages/body/test/options.test.ts create mode 100644 packages/body/test/process.test.ts create mode 100644 packages/body/test/types.test.ts diff --git a/packages/body/README.md b/packages/body/README.md index be34782e..e8590cdc 100644 --- a/packages/body/README.md +++ b/packages/body/README.md @@ -1,17 +1,18 @@ # @binden/body [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![version](https://img.shields.io/npm/v/@binden/body?style=plastic)](https://www.npmjs.com/package/@binden/body) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/binden-js/binden/blob/main/CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![node version](https://img.shields.io/node/v/@binden/body) ![npm downloads](https://img.shields.io/npm/dt/@binden/body) [![License](https://img.shields.io/npm/l/@binden/body)](https://github.com/binden-js/binden/blob/main/packages/body/LICENSE) -[Binden](https://github.com/binden-js/binden) body parser middleware. Supports the following [content types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) +[Binden](https://github.com/binden-js/binden) body parser middleware: it reads the body of a request +into `request.body`, once its [content codings](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding) +are removed, as -- `application/json` -- `application/x-www-form-urlencoded` -- `text/plain` +| Kind | [Content types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) (by default) | `request.body` | Empty body | +| ------ | ---------------------------------------------------------------------------------------------------- | -------------------------------------- | ----------------------- | +| `json` | `application/json`, and any `+json` type (e.g. `application/ld+json`) | the result of `parse` (`JSON.parse()`) | `undefined` | +| `text` | `text/plain` | a `string`, decoded with its charset | `""` | +| `form` | `application/x-www-form-urlencoded` | `URLSearchParams` | empty `URLSearchParams` | +| `raw` | `application/octet-stream` (with `raw: true` only) | a `Buffer` | empty `Buffer` | -and any combination of the following [encodings](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding) - -- `br` -- `x-gzip` -- `gzip` -- `deflate` +Any other body is **skipped**: it is not read, and is left to the next middlewares (e.g. +[`@binden/multipart`](https://github.com/binden-js/binden/tree/main/packages/multipart)). ## Installation @@ -22,37 +23,123 @@ npm install @binden/body ## Usage ```typescript -import { type Context, Middleware, Router } from "binden"; -import BodyParser from "@binden/body"; +import { type Context, Router } from "binden"; +import { BodyParser } from "@binden/body"; + +// In production, limit the size of the bodies (here 1 MiB) +const body = new BodyParser({ limit: 1024 * 1024, encoded_limit: 1024 * 1024 }); + +const deposit = (context: Context): Promise => { + const { body: parsed } = context.request; + return context.json({ received: parsed !== undefined }); +}; + +app.use("/deposit", new Router().post(body, deposit)); +``` + +The default export is a `BodyParser` with the default options (no limits). + +## Options + +| Option | Default | Description | +| ----------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `limit` | none (`Infinity`) | The largest body in bytes once decoded, at every stage of the decoding: a larger one gets `413` | +| `encoded_limit` | none (`Infinity`) | The largest body in bytes as received: a larger `Content-Length` gets `413` before the body is read, as does a larger body | +| `parameter_limit` | `1000` | The most parameters of a form: more get `413` | +| `type` | JSON, text, forms | The bodies to parse: a list of media types, or a function of the `Content-Type` (see [Selecting bodies](#selecting-bodies)) | +| `raw` | `false` | Whether `application/octet-stream` is parsed, as a `Buffer` | +| `parse` | `JSON.parse` | Parses a JSON body, synchronously or not (see [Custom `parse`](#custom-parse)) | +| `strict` | `true` | Whether the default `parse` accepts only an object or an array | +| `disabled` | `false` | Whether the middleware is skipped | +| `ignore_errors` | `false` | Whether its errors are ignored | + +An invalid option throws a `TypeError` (or a `RangeError`) in the constructor. The limits are +non-negative integers, or `Infinity` (no limit). + +## Limits + +The body is read into memory, and **nothing limits its size by default**: a small compressed body +may decode to gigabytes. In production, set `limit` (and `encoded_limit`) explicitly, e.g. to 1 MiB. + +- `encoded_limit` counts the bytes as received: a larger `Content-Length` is refused before the + body is read, and a body without it (chunked) is counted while it is read. +- `limit` counts the bytes that every decoder outputs (a counter follows each of them, the + intermediate ones too), so that a body is not decoded beyond it at any stage. Without content + codings, a larger `Content-Length` is refused before the body is read too. +- `parameter_limit` counts the parameters of a form before they are parsed. + +A body that exceeds a limit gets `413 Content Too Large`. A body that is refused before it has been +read completely is not read further: the request is paused (not destroyed, so that the reply is +sent), every decoder is destroyed, and the connection is closed after the reply +(`Connection: close`). + +## Selecting bodies -class MyMiddleware extends Middleware { - public run(context: Context): void { - const { body } = context.request; - context.log.info("Body has been parsed (or not)", { body }); - } -} +`type` replaces the default selection, as a list of media types: `type/subtype`, `type/*`, `*/*`, or +a structured syntax suffix such as `+json`. Their kind is the one of the content type of the body: +`json` for `application/json` and `+json` types, `form` for `application/x-www-form-urlencoded`, +`text` for `text/*`, `raw` for any other type. `raw: true` adds `application/octet-stream` to the +list. -app.use("/deposit", new Router().post(BodyParser, new MyMiddleware())); +```typescript +import { BodyParser } from "@binden/body"; + +// CSV and HTML as text, and images as bytes +const uploads = new BodyParser({ + type: ["text/csv", "text/html", "image/*"], + limit: 10 * 1024 * 1024, +}); ``` -### Body size +A function of the `Content-Type` returns the kind of the body instead (`null` skips it). It is +called synchronously: one that throws, or that returns anything else than a kind or `null` (e.g. a +promise), is an error of the configuration (logged at `error`, `500 Internal Server Error`). + +```typescript +import type { ContentType } from "binden"; +import { BodyParser, type IBodyKind } from "@binden/body"; + +// Content Security Policy reports are JSON +const reports = new BodyParser({ + type: (content_type: ContentType): IBodyKind | null => + content_type.type === "application/csp-report" ? "json" : null, + limit: 64 * 1024, +}); +``` -The body is read into memory, and nothing limits its size by default: a small compressed body -may decode to gigabytes. Set `limit` (in bytes, of the decoded body) to reply -`413 Content Too Large` to a larger body, which is then not read further (the connection is -closed after the reply), for example 1 MiB: +A body that is not selected is skipped (it is not read, and the parser does not mark it as +parsed), so that `BodyParser` and `Multipart` can be used together: ```typescript import { BodyParser } from "@binden/body"; +import { Multipart } from "@binden/multipart"; -const body_parser = new BodyParser({ limit: 1024 * 1024 }); +// JSON, text and forms by the first, `multipart/form-data` by the second +app.use(new BodyParser({ limit: 1024 * 1024 }), new Multipart()); ``` -### Custom `parse` function +A body is parsed once: another `BodyParser` that selects it later skips it. + +## Content codings + +`gzip`, `x-gzip`, `deflate` and `br` are decoded, as are two of them combined (e.g. +`Content-Encoding: gzip, br`). More than two codings, an unknown or unsupported coding (e.g. +`compress`) and an unknown charset (of a body that is not `raw`) get `415 Unsupported Media Type`, +before the body is read; corrupt data gets `400 Bad Request`. The codings of a body that is not +selected are not checked. -`parse` replaces `JSON.parse()` for `application/json`. A body that it cannot parse (it throws, or -returns `undefined`, as the parsers of [Ajv](https://ajv.js.org/guide/getting-started.html#parsing-and-serializing-json) -do) gets a `415 Unsupported Media Type`. +## Custom `parse` + +`parse` replaces `JSON.parse()` for the JSON bodies, synchronously or not: its result (or what its +promise resolves to) is `request.body`, whose type is inferred from it. It gets the body decoded +with its charset (an empty body is `undefined`, and a body of whitespace only is a `400` without +calling `parse`), and `strict` does not apply to it. + +- `undefined` (as the parsers of [Ajv](https://ajv.js.org/guide/getting-started.html#parsing-and-serializing-json) + return for an invalid body, with the reason in their `message`) is a `400 Bad Request`; +- a `BindenError` that it throws is the reply; +- any other failure is a `500 Internal Server Error`: a parser should reject an invalid body with + `undefined`, or with a `BindenError` (e.g. `422`). ```typescript import { Binden, type Context, Router } from "binden"; @@ -65,17 +152,44 @@ const schema = { properties: { username: { type: "string" }, password: { type: "string" } }, additionalProperties: false, } as const; -const parse = ajv.compileParser>(schema); -const body_parser = new BodyParser({ parse }); -const echo = (context: Context): Promise => { - const body = context.request.body as JTDDataType; - context.log.info("Body has been parsed (or not)", { body }); - return context.json(body); +type ILogin = JTDDataType; +const parse = ajv.compileParser(schema); +// `BodyParser` +const body_parser = new BodyParser({ parse, limit: 1024 * 1024 }); +const login = (context: Context): Promise => { + const { username } = context.request.body as ILogin; + return context.json({ username }); }; new Binden() - .use("/login", new Router().post(body_parser, echo)) + .use("/login", new Router().post(body_parser, login)) .createServer() .listen(port, () => { console.log(`Server is listening on the port ${port}`); }); ``` + +## Aborted requests + +The reading and `parse` stop once `context.signal` is aborted: a promise of `parse` that never +settles is abandoned (its late rejection is observed), and the parser does not stay pending. + +- A client that goes away gets a `BindenError(400, { code: "aborted" })` (logged at `debug`). +- After the deadline of the request (or the timeout of a shutdown), the parser rejects with the + reason of the signal (a `BindenAbortError`), and the connection is closed after the reply of + the framework. + +## Errors + +| Situation | Reply | +| ----------------------------------------------------------------------------------------------------------------------- | ------------------------------ | +| Invalid JSON (for the default `parse`), whitespace only, not an object or an array (`strict`), `undefined` from `parse` | `400 Bad Request` | +| Corrupt compressed data, bytes that are not valid in the charset | `400 Bad Request` | +| A client that goes away | `400 Bad Request` (`aborted`) | +| A limit is exceeded (`limit`, `encoded_limit`, `parameter_limit`) | `413 Content Too Large` | +| An unknown or unsupported content coding, more than two codings, an unknown charset | `415 Unsupported Media Type` | +| A content type that is not selected | skipped (the body is not read) | +| A `BindenError` of `parse` | that error | +| Any other failure of `parse`, a `type` function that fails, a body that something else has read | `500 Internal Server Error` | + +The parser never logs the body itself: its logs have the reason of the refusal, and the type, +known `code` and status of an error. diff --git a/packages/body/index.ts b/packages/body/index.ts index d9a6f7ea..541790f8 100644 --- a/packages/body/index.ts +++ b/packages/body/index.ts @@ -1,191 +1,615 @@ -import { finished } from "node:stream"; -import { createInflate, createGunzip, createBrotliDecompress } from "node:zlib"; +import { addAbortListener } from "node:events"; import { + type Duplex, + Transform, + Writable, + finished, + pipeline, +} from "node:stream"; +import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib"; +import { + BindenAbortError, BindenError, - Middleware, + type ContentType, + type Context, type IMiddlewareParams, - Context, - ct_text, - ct_json, - ct_form, + Middleware, + serializers, } from "binden"; -import type { Duplex, Readable } from "stream"; +/** How a selected body is parsed: JSON (with `parse`), text, a form or raw bytes */ +export type IBodyKind = "form" | "json" | "raw" | "text"; -export type IBodyContentType = typeof ct_form | typeof ct_json | typeof ct_text; +/** + * The bodies to parse: a list of media types (`type/subtype`, `type/*`, + * `* /*`, or a structured syntax suffix such as `+json`), or a function of + * the `Content-Type` that returns the kind of the body (`null` skips it) + */ +export type IBodyType = + readonly string[] | ((content_type: ContentType) => IBodyKind | null); -export type IParse = (input: string) => T | undefined; +/** + * Parses a JSON body into its final value (`T`), synchronously or not: + * `undefined` tells that the body is invalid (`400 Bad Request`) + */ +export type IParse = ( + input: string, +) => T | undefined | Promise; +/** The options of a `BodyParser` */ export interface IBodyParserOptions extends IMiddlewareParams { - parse?: IParse; /** - * The largest body in bytes, once decoded (no limit by default): a larger - * one is not read further, and gets a `413 Content Too Large` + * Parses a JSON body (`JSON.parse()` by default): its result (or what its + * promise resolves to) is `request.body` + */ + parse?: IParse | undefined; + /** + * The largest body in bytes once its content codings are removed, at every + * stage of the decoding (no limit by default, `Infinity`): a larger one + * gets a `413 Content Too Large` + */ + limit?: number | undefined; + /** + * The largest body in bytes as received (no limit by default, `Infinity`): + * a larger `Content-Length` gets a `413 Content Too Large` before the body + * is read, as does a larger body once read + */ + encoded_limit?: number | undefined; + /** + * The bodies to parse (JSON, `+json`, `text/plain` and forms by default): + * the others are skipped, and left to the next middlewares */ - limit?: number; + type?: IBodyType | undefined; + /** Whether `application/octet-stream` is parsed, as a `Buffer` (`false`) */ + raw?: boolean | undefined; + /** Whether the default `parse` accepts only an object or an array (`true`) */ + strict?: boolean | undefined; + /** The most parameters of a form (`1000`): more get a `413 Content Too Large` */ + parameter_limit?: number | undefined; +} + +/** A decoder of a content coding, created once every coding is known to be supported */ +type IDecoder = () => Duplex; + +/** The outcome of a callback of the options that has settled */ +type IOutcome = + | { readonly ok: true; readonly value: R } + | { readonly ok: false; readonly error: unknown }; + +/** The logger of the parser (`context.log`, whose calls never fail) */ +type ILog = Context["log"]; + +/** The most content codings that a body may have */ +const MAX_CODINGS = 2; + +const kinds: ReadonlySet = new Set(["form", "json", "raw", "text"]); + +const token = "[!#$%&'*+.^_`|~\\w-]+"; + +/** `type/subtype` (either may be `*`), or a structured syntax suffix (`+json`) */ +const mediaRange = new RegExp(`^(?:${token}/${token}|\\+${token})$`, "u"); + +/** A JSON text that the strict default `parse` accepts: an object or an array */ +const strictJSON = /^[\t\n\r ]*[[{]/u; + +/** The requests whose body has been claimed by a parser (read, or being read) */ +const claimed = new WeakSet(); + +function isKind(value: unknown): value is IBodyKind { + return typeof value === "string" && kinds.has(value); +} + +/** Does nothing (e.g. with a rejection that is observed) */ +function ignore(): void { + // Observed +} + +/** The outcome of a call that may throw */ +function attempt(call: () => R): IOutcome { + try { + return { ok: true, value: call() }; + } catch (error: unknown) { + return { ok: false, error }; + } +} + +/** Whether a media type matches an entry of `type` */ +function matches(entry: string, type: string): boolean { + if (entry.startsWith("+")) { + return type.endsWith(entry) && !type.endsWith(`/${entry}`); + } + const [range = "", subrange = ""] = entry.split("/"); + const [main = "", sub = ""] = type.split("/"); + return ( + (range === "*" || range === main) && (subrange === "*" || subrange === sub) + ); } +/** The kind of a media type that a list of `type` selects */ +function kindOf(type: string): IBodyKind { + if (type === "application/json" || type.endsWith("+json")) { + return "json"; + } else if (type === "application/x-www-form-urlencoded") { + return "form"; + } else if (type.startsWith("text/")) { + return "text"; + } + return "raw"; +} + +/** Whether a form has more than `max` parameters (counted without parsing it) */ +function tooManyParameters(form: string, max: number): boolean { + let count = 0; + let start = 0; + while (count <= max) { + const end = form.indexOf("&", start); + const stop = end === -1 ? form.length : end; + if (stop > start) { + count += 1; + } + if (end === -1) { + break; + } + start = end + 1; + } + return count > max; +} + +/** A limit: a non-negative integer, or `Infinity` */ +function validateLimit(name: string, value: unknown): number { + if (typeof value !== "number" || Number.isNaN(value)) { + throw new TypeError(`\`${name}\` is not a number`); + } else if ( + value !== Infinity && + !(Number.isSafeInteger(value) && value >= 0) + ) { + throw new RangeError( + `\`${name}\` is not a non-negative integer or \`Infinity\``, + ); + } + return value; +} + +function validateBoolean(name: string, value: unknown): boolean { + if (typeof value !== "boolean") { + throw new TypeError(`\`${name}\` is not a boolean`); + } + return value; +} + +/** The entries of a list of `type`, in lowercase */ +function validateTypes(type: unknown): readonly string[] { + if (!Array.isArray(type)) { + throw new TypeError("`type` is not an array or a function"); + } + return type.map((entry: unknown) => { + if (typeof entry !== "string" || !mediaRange.test(entry)) { + throw new TypeError(`Invalid media type in \`type\`: ${String(entry)}`); + } + return entry.toLowerCase(); + }); +} + +/** + * The error of an aborted request: a client that has gone away is a + * `400 Bad Request` (`aborted`), a deadline or a shutdown is the reason of + * `context.signal` + */ +function abortError(reason: unknown): Error { + return reason instanceof BindenAbortError && reason.reason !== "client_closed" + ? reason + : new BindenError(400, { code: "aborted", cause: reason }); +} + +/** + * Counts the bytes that pass through it: more than `limit` fails it with a + * `413 Content Too Large` + */ +function counter(limit: number): Transform { + let size = 0; + return new Transform({ + transform(chunk: Buffer, _encoding, callback): void { + size += chunk.byteLength; + if (size > limit) { + callback(new BindenError(413)); + return; + } + callback(null, chunk); + }, + }); +} + +/** + * Parses the body of a request into `request.body`: JSON (with `parse`), + * text, a form (`URLSearchParams`) or raw bytes (a `Buffer`), once its + * content codings are removed. The bodies that `type` does not select are + * skipped (e.g. left to `@binden/multipart`). + */ export class BodyParser extends Middleware { - readonly #parse: IParse; - readonly #limit: number | null; + readonly #parse: IParse | null; + readonly #limit: number; + readonly #encoded_limit: number; + readonly #types: readonly string[] | null; + readonly #type: ((content_type: ContentType) => unknown) | null; + readonly #strict: boolean; + readonly #parameter_limit: number; - public constructor({ parse, limit, ...rest }: IBodyParserOptions = {}) { + public constructor({ + parse, + limit = Infinity, + encoded_limit = Infinity, + type, + raw = false, + strict = true, + parameter_limit = 1000, + ...rest + }: IBodyParserOptions = {}) { super(rest); if (typeof parse !== "undefined" && typeof parse !== "function") { throw new TypeError("`parse` is not a function"); - } else if ( - typeof limit !== "undefined" && - !(Number.isSafeInteger(limit) && limit >= 0) - ) { - throw new TypeError("`limit` is not a non-negative integer"); } - this.#parse = parse ?? JSON.parse; - this.#limit = limit ?? null; + this.#limit = validateLimit("limit", limit); + this.#encoded_limit = validateLimit("encoded_limit", encoded_limit); + this.#parameter_limit = validateLimit("parameter_limit", parameter_limit); + validateBoolean("raw", raw); + this.#strict = validateBoolean("strict", strict); + + if (typeof type === "function") { + if (raw) { + throw new TypeError("`raw` cannot be used with a `type` function"); + } + this.#type = type; + this.#types = null; + } else { + const types = + typeof type === "undefined" + ? BodyParser.#defaults + : validateTypes(type); + this.#types = raw ? [...types, "application/octet-stream"] : types; + this.#type = null; + } + + // `JSON.parse` itself is the default parse + this.#parse = + typeof parse === "undefined" || (parse as unknown) === JSON.parse + ? null + : parse; } public async run(context: Context): Promise { - const { name: middleware } = BodyParser; - const { request, log: logger } = context; - const log = logger.child({ middleware }); - const { destroyed, method = "GET" } = request; + const { request } = context; + const log = context.log.child({ middleware: BodyParser.name }); + const { method = "GET", content_type } = request; if (BodyParser.#methods.has(method)) { - log.debug("Unsupported method", { method }); + log.debug("A method without a body", { method }); return; } - if (destroyed) { - log.debug("Skip parsing", { destroyed }); + const kind = content_type === null ? null : this.#kind(content_type, log); + if (kind === null) { + log.debug("The `Content-Type` is not selected", { + content_type: content_type?.type ?? null, + }); + return; + } else if (claimed.has(request)) { + log.debug("The body has already been parsed"); return; } + claimed.add(request); - const { content_type, content_encoding } = request; + const { decoders, decoder } = this.#prepare(context, kind, log); + const decoded = await this.#read(context, decoders, log); - const type = content_type?.type ?? null; + let text = ""; + if (decoder) { + try { + text = decoder.decode(decoded); + } catch (error: unknown) { + log.debug("The body is not valid in its charset", { + charset: decoder.encoding, + }); + throw new BindenError(400, { cause: error }); + } + } - if (type !== ct_json && type !== ct_text && type !== ct_form) { - log.debug("Unsupported Content-Type", { content_type: type }); + if (kind === "json" && text === "") { + // An empty JSON body has no value (`undefined`) return; } + request.body = + kind === "json" + ? await this.#json(text, context, log) + : kind === "form" + ? this.#form(text, log) + : kind === "text" + ? text + : decoded; + } - if (content_encoding === null) { - // An unknown (or malformed) coding: the body cannot be decoded - log.debug("Unsupported encoding"); - throw new BindenError(415); + /** The kind of a body, `null` when `type` does not select it */ + #kind(content_type: ContentType, log: ILog): IBodyKind | null { + if (this.#types !== null) { + const { type } = content_type; + return this.#types.some((entry) => matches(entry, type)) + ? kindOf(type) + : null; } - // The text is decoded with its charset (UTF-8 by default) - const charset = content_type?.charset ?? "utf-8"; - const decoder = ((): TextDecoder => { - try { - return new TextDecoder(charset); - } catch (error: unknown) { - log.debug("Unsupported charset", { charset }); - throw new BindenError(415, { cause: error as Error }); - } - })(); - - const streams: [string, Duplex][] = []; - - for (const { encoding } of content_encoding) { - if (encoding === "gzip" || encoding === "x-gzip") { - streams.push([encoding, createGunzip()]); - } else if (encoding === "deflate") { - streams.push([encoding, createInflate()]); - } else if (encoding === "br") { - streams.push([encoding, createBrotliDecompress()]); - } else { - log.debug("Unsupported encoding", { encoding }); - throw new BindenError(415); + const select = this.#type as (content_type: ContentType) => unknown; + const outcome = attempt(() => select(content_type)); + if (!outcome.ok) { + log.error("The `type` function has failed", { + failure: serializers.error(outcome.error), + }); + throw new BindenError(500, { cause: outcome.error }); + } + const kind = outcome.value; + if (kind === null || isKind(kind)) { + return kind; + } + // E.g. a promise, whose rejection is observed + Promise.resolve(kind).catch(ignore); + log.error("The `type` function has returned an invalid kind"); + throw new BindenError(500); + } + + /** + * Checks what can be checked before the body is read: the request, its + * content codings (decoders are created only once all are supported), its + * charset and its `Content-Length`. A refusal closes the connection after + * the reply, as the body is not read. + */ + #prepare( + context: Context, + kind: IBodyKind, + log: ILog, + ): { decoders: IDecoder[]; decoder: TextDecoder | null } { + const { request } = context; + const refuse = (error: Error, message: string, extra?: object): Error => { + log.debug(message, { ...extra }); + if (!context.response.headersSent) { + context.setHeader("Connection", "close"); } + return error; + }; + + // An aborted signal stops the reading (see `#read()`) + if (request.readableDidRead || request.readableEnded) { + // E.g. by a middleware that has read the stream itself + log.error("The body has already been read"); + throw new BindenError(500); + } else if (request.destroyed) { + throw refuse(abortError(null), "The request has been aborted"); } - const body = await new Promise((resolve, reject) => { - let { request: stream } = context as { request: Readable }; - const source = stream; + const codings = request.content_encoding; + if (codings === null) { + throw refuse(new BindenError(415), "Unsupported content coding"); + } else if (codings.length > MAX_CODINGS) { + throw refuse(new BindenError(415), "Too many content codings", { + codings: codings.length, + }); + } - for (const [encoding, decompresser] of streams) { - decompresser.once("error", (error) => { - log.debug("Decoding failed", { error, encoding }); - reject(new BindenError(415, { cause: error })); + const decoders: IDecoder[] = []; + for (const { encoding } of codings) { + const decoder = BodyParser.#decoder(encoding); + if (decoder === null) { + throw refuse(new BindenError(415), "Unsupported content coding", { + encoding, }); - stream.pipe(decompresser); - stream = decompresser; } + decoders.push(decoder); + } + + let decoder: TextDecoder | null = null; + if (kind !== "raw") { + const charset = context.request.content_type?.charset ?? "utf-8"; + try { + decoder = new TextDecoder(charset, { fatal: true }); + } catch (error: unknown) { + const unsupported = new BindenError(415, { cause: error }); + throw refuse(unsupported, "Unsupported charset", { charset }); + } + } - // `pipe` does not forward an aborted request to the decoders: destroy them, - // so that the last one finishes (with a premature close) and settles this promise - finished(source, (error) => { - if (error) { - for (const [, decompresser] of streams) { - decompresser.destroy(); - } - } - }); + const length = Number(request.headers["content-length"] ?? Number.NaN); + if ( + length > this.#encoded_limit || + (decoders.length === 0 && length > this.#limit) + ) { + throw refuse(new BindenError(413), "The body is too large", { length }); + } + + return { decoders, decoder }; + } - const chunks: Buffer[] = []; - let size = 0; - const limit = this.#limit; + /** The decoder of a content coding, `null` when it is not supported */ + static #decoder(encoding: string): IDecoder | null { + if (encoding === "gzip" || encoding === "x-gzip") { + return createGunzip; + } else if (encoding === "deflate") { + return createInflate; + } else if (encoding === "br") { + return createBrotliDecompress; + } + return null; + } - stream.on("data", (data: Buffer) => { - size += data.byteLength; - if (limit === null || size <= limit) { - chunks.push(data); + /** + * Reads the body through its decoders, each followed by a counter of its + * output (`limit`), after a counter of the body as received + * (`encoded_limit`). A refusal (or an abort) stops the reading: the + * request is paused (not destroyed, so that the reply is sent), every + * decoder destroyed and the connection closed after the reply. + */ + #read( + context: Context, + decoders: readonly IDecoder[], + log: ILog, + ): Promise { + const { request, response, signal } = context; + const wire = counter(this.#encoded_limit); + const stages: Duplex[] = [wire]; + for (const create of decoders) { + stages.push(create(), counter(this.#limit)); + } + if (decoders.length === 0) { + stages.push(counter(this.#limit)); + } + const chunks: Buffer[] = []; + const sink = new Writable({ + write(chunk: Buffer, _encoding, callback): void { + chunks.push(chunk); + callback(); + }, + }); + + return new Promise((resolve, reject) => { + let settled = false; + const cleanups: (() => void)[] = []; + const settle = (): boolean => { + if (settled) { + return false; + } + settled = true; + for (const cleanup of cleanups) { + cleanup(); + } + return true; + }; + const fail = (error: Error, message: string): void => { + if (!settle()) { return; } - // Not read further (e.g. a small compressed body that would decode to - // gigabytes): the connection is closed after the reply - log.debug("The body is too large", { limit }); - source.unpipe(); - source.pause(); - stream.removeAllListeners("data"); - for (const [, decompresser] of streams) { - decompresser.destroy(); + log.debug(message, { failure: serializers.error(error) }); + // Paused (once nothing else reads it), not destroyed: the reply is sent + request.unpipe(wire); + for (const stream of [...stages, sink]) { + stream.destroy(); } - context.setHeader("Connection", "close"); - reject(new BindenError(413)); - }); + if (!response.headersSent) { + response.setHeader("Connection", "close"); + } + reject(error); + }; - finished(stream, (error) => { - if (error) { - reject(error); - } else { - resolve(decoder.decode(Buffer.concat(chunks))); + const listener = addAbortListener(signal, () => { + fail(abortError(signal.reason), "The request has been aborted"); + }); + cleanups.push( + () => { + listener[Symbol.dispose](); + }, + finished(request, (error) => { + if (error) { + fail(abortError(error), "The request has been aborted"); + } + }), + ); + pipeline([...stages, sink], (error) => { + if (error instanceof BindenError) { + fail(error, "The body is too large"); + } else if (error) { + const invalid = new BindenError(400, { cause: error }); + fail(invalid, "The body cannot be decoded"); + } else if (settle()) { + resolve(Buffer.concat(chunks)); } }); + request.pipe(wire); }); + } - try { - request.body = - type === ct_json - ? this.#json(body) - : type === ct_form - ? new URLSearchParams(body) - : body; - } catch (error: unknown) { - log.debug("Request body does not match provided Content-Type", { - error, - type, - body, + /** + * The value of a JSON body that is not empty: a `400 Bad Request` when it + * has only whitespace or cannot be parsed + */ + async #json(text: string, context: Context, log: ILog): Promise { + if (text.trim() === "") { + log.debug("The JSON body has only whitespace"); + throw new BindenError(400); + } + + if (this.#parse === null) { + if (this.#strict && !strictJSON.test(text)) { + log.debug("The JSON body is not an object or an array"); + throw new BindenError(400); + } + try { + return JSON.parse(text) as unknown; + } catch (error: unknown) { + log.debug("The JSON body cannot be parsed"); + throw new BindenError(400, { cause: error }); + } + } + + const parse = this.#parse; + const outcome = await BodyParser.#call(() => parse(text), context, log); + if (!outcome.ok) { + log.debug("`parse` has failed", { + failure: serializers.error(outcome.error), + }); + throw outcome.error instanceof BindenError + ? outcome.error + : new BindenError(500, { cause: outcome.error }); + } else if (typeof outcome.value === "undefined") { + // E.g. a parser of `ajv`, which tells why in its `message` + const { message } = parse as { message?: unknown }; + log.debug("`parse` has not parsed the body"); + throw new BindenError(400, { + ...(typeof message === "string" ? { message } : {}), }); + } + return outcome.value; + } - throw new BindenError(415, { cause: error as Error }); + /** The parameters of a form, at most `parameter_limit` of them */ + #form(text: string, log: ILog): URLSearchParams { + if (tooManyParameters(text, this.#parameter_limit)) { + log.debug("The form has too many parameters", { + parameter_limit: this.#parameter_limit, + }); + throw new BindenError(413); } + return new URLSearchParams(text); } /** - * The value of a JSON body: `parse` throws, or returns `undefined` (e.g. a - * parser of `ajv`, which tells why in its `message`), when it cannot parse it + * Calls a callback of the options (`parse`) and awaits its + * result, unless the request is aborted first: its late result is then + * ignored, and its late rejection observed */ - #json(body: string): T { - const parsed = this.#parse(body); - if (typeof parsed === "undefined") { - const { message } = this.#parse as { message?: unknown }; - throw new Error( - typeof message === "string" ? message : "The body could not be parsed", - ); + static async #call( + call: () => R | PromiseLike, + context: Context, + log: ILog, + ): Promise> { + const { signal } = context; + const outcome = await new Promise | null>((resolve) => { + // Called (in a microtask) when the signal has already been aborted + const listener = addAbortListener(signal, () => { + resolve(null); + }); + Promise.resolve() + .then(call) + .then( + (value) => { + listener[Symbol.dispose](); + resolve({ ok: true, value }); + }, + (error: unknown) => { + listener[Symbol.dispose](); + resolve({ ok: false, error }); + }, + ); + }); + if (outcome === null) { + log.debug("The request has been aborted"); + throw abortError(signal.reason); } - return parsed; + return outcome; } /** Set of methods with no request body */ @@ -199,6 +623,14 @@ export class BodyParser extends Middleware { "OPTIONS", "TRACE", ]); + + /** The bodies parsed by default */ + static readonly #defaults: readonly string[] = [ + "application/json", + "+json", + "text/plain", + "application/x-www-form-urlencoded", + ]; } export default new BodyParser(); diff --git a/packages/body/test/abort.test.ts b/packages/body/test/abort.test.ts index 8729442f..290b3e25 100644 --- a/packages/body/test/abort.test.ts +++ b/packages/body/test/abort.test.ts @@ -1,78 +1,267 @@ -/* eslint-disable init-declarations */ -import { ok } from "node:assert/strict"; -import { type Server, request } from "node:http"; -import type { AddressInfo } from "node:net"; -import { afterEach, beforeEach, suite, test } from "node:test"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { once } from "node:events"; +import { type IncomingMessage, request } from "node:http"; +import { suite, test } from "node:test"; import { gzipSync } from "node:zlib"; -import { Binden, type Context, ct_text } from "binden"; +import { + Binden, + BindenAbortError, + BindenError, + type Context, + type IFunctionMiddleware, + ct_json, + ct_text, +} from "binden"; import { BodyParser } from "../index.js"; +import { type IServer, Recorder, listen, never, within } from "./helpers.js"; -let port = 0; +/** How the parser has settled */ +type IOutcome = { error: unknown } | { ok: true }; -suite("BodyParser (aborted request)", () => { - let server: Server; - let started: Promise; - let settled: Promise; +/** Runs a parser, and tells when it has started and how it has settled */ +function watch( + parser: BodyParser, + before: (context: Context) => Promise | void = (): void => { + // Nothing + }, +): { + middleware: IFunctionMiddleware; + started: Promise; + settled: Promise; +} { + const { promise: started, resolve: start } = Promise.withResolvers(); + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const middleware = async (context: Context): Promise => { + await before(context); + start(context); + try { + await parser.run(context); + settle({ ok: true }); + } catch (error: unknown) { + settle({ error }); + throw error; + } + }; + return { middleware, started, settled }; +} - beforeEach((_t, done) => { - let start: () => void; - let settle: (value: unknown) => void; - started = new Promise((resolve) => { - start = resolve; - }); - settled = new Promise((resolve) => { - settle = resolve; - }); +/** Sends the headers and the first chunk of a body, without ending it */ +function upload( + server: IServer, + headers: Record, + chunk: Buffer | string, +): { req: ReturnType; response: Promise } { + const req = request({ + port: server.port, + method: "POST", + agent: false, + headers, + }); + const response = new Promise((resolve, reject) => { + req.once("response", resolve).once("error", reject); + }); + // Observed by the tests that expect it + response.catch(() => { + // The request may be aborted on purpose + }); + req.write(chunk); + return { req, response }; +} - const parser = new BodyParser(); - const app = new Binden().use(async (ct: Context) => { - start(); - try { - await parser.run(ct); - settle(null); - } catch (error: unknown) { - settle(error); +/** The error of a settled parser */ +function failure(outcome: IOutcome): unknown { + ok("error" in outcome, "The parser has not failed"); + return outcome.error; +} + +/** Whether an error is the `400 Bad Request` of an aborted request */ +function isAborted(error: unknown): boolean { + return ( + error instanceof BindenError && + error.status === 400 && + error.code === "aborted" + ); +} + +suite("BodyParser (aborted requests)", () => { + test( + "a client that goes away during the upload", + { timeout: 10_000 }, + async () => { + for (const [headers, body] of [ + [ + { "Content-Encoding": "gzip" }, + gzipSync("Hello World".repeat(10_000)), + ], + [{}, Buffer.alloc(64 * 1024, 0x61)], + ] as const) { + const watched = watch(new BodyParser()); + const logger = new Recorder(); + const server = await listen( + new Binden({ logger }).use(watched.middleware), + ); + try { + const { req } = upload( + server, + { + "Content-Type": ct_text, + "Content-Length": body.length, + ...headers, + }, + body.subarray(0, body.length / 2), + ); + await within(watched.started); + req.destroy(); + ok(isAborted(failure(await within(watched.settled)))); + ok(logger.messages("debug").includes("The request has been aborted")); + deepStrictEqual(logger.messages("error"), []); + } finally { + await server.close(); + } } - }); + }, + ); - server = app.createServer().listen(0, () => { - ({ port } = server.address() as AddressInfo); - done(); - }); + test("a deadline during the upload", { timeout: 10_000 }, async () => { + const watched = watch(new BodyParser()); + const server = await listen( + new Binden({ timeout: 100 }).use(watched.middleware), + ); + try { + const { req, response } = upload( + server, + { "Content-Type": ct_text, "Content-Length": 1000 }, + "a", + ); + const reply = await within(response); + reply.resume(); + deepStrictEqual( + [reply.statusCode, reply.headers.connection], + [503, "close"], + ); + const error = failure(await within(watched.settled)); + ok(error instanceof BindenAbortError); + deepStrictEqual(error.reason, "deadline"); + req.destroy(); + } finally { + await server.close(); + } }); test( - "settles when a compressed upload is aborted", - { timeout: 5000 }, + "a `parse` that never settles is abandoned once the client has gone away", + { timeout: 10_000 }, async () => { - const body = gzipSync("Hello World".repeat(10_000)); - const req = request({ - port, - method: "POST", - headers: { - "Content-Type": ct_text, - "Content-Encoding": "gzip", - "Content-Length": body.length, - }, - }); - req.on("error", () => { - // The request is aborted on purpose - }); - req.write(body.subarray(0, body.length / 2)); + const { promise: parsing, resolve } = Promise.withResolvers(); + const parse = (): Promise => { + resolve(null); + return never(); + }; + const watched = watch(new BodyParser({ parse })); + const server = await listen(new Binden().use(watched.middleware)); + try { + const { req } = upload( + server, + { "Content-Type": ct_json, "Content-Length": 2 }, + "[]", + ); + req.end(); + await within(parsing); + req.destroy(); + ok(isAborted(failure(await within(watched.settled)))); + } finally { + await server.close(); + } + }, + ); - await started; - await new Promise((resolve) => { - setTimeout(resolve, 20); + test( + "a request that has been aborted before the parser runs", + { timeout: 10_000 }, + async () => { + // Waits for the deadline, then runs the parser + const watched = watch(new BodyParser(), async (context) => { + await once(context.signal, "abort"); }); - req.destroy(); + const server = await listen( + new Binden({ timeout: 50 }).use(watched.middleware), + ); + try { + const { req, response } = upload( + server, + { "Content-Type": ct_text, "Content-Length": 2 }, + "ab", + ); + req.end(); + (await within(response)).resume(); + const error = failure(await within(watched.settled)); + ok(error instanceof BindenAbortError); + deepStrictEqual(error.reason, "deadline"); + } finally { + await server.close(); + } + }, + ); - ok((await settled) instanceof Error); + test( + "a request that has been destroyed before the parser runs", + { timeout: 10_000 }, + async () => { + const watched = watch(new BodyParser(), (context) => { + context.request.destroy(); + }); + const server = await listen(new Binden().use(watched.middleware)); + try { + const { req } = upload( + server, + { "Content-Type": ct_text, "Content-Length": 2 }, + "ab", + ); + req.on("error", () => { + // The server has closed the connection + }); + req.end(); + const error = failure(await within(watched.settled)); + ok(isAborted(error)); + deepStrictEqual((error as Error).cause, null); + } finally { + await server.close(); + } }, ); - afterEach((_t, done) => { - server.closeAllConnections(); - server.close(done); - }); + test( + "a request stream that fails while the client waits", + { timeout: 10_000 }, + async () => { + const streamError = new Error("A failure of the stream"); + const watched = watch(new BodyParser(), (context) => { + setTimeout(() => { + context.request.emit("error", streamError); + }, 20); + }); + const server = await listen(new Binden().use(watched.middleware)); + try { + const { req, response } = upload( + server, + { "Content-Type": ct_text, "Content-Length": 1000 }, + "a", + ); + const reply = await within(response); + reply.resume(); + deepStrictEqual( + [reply.statusCode, reply.headers.connection], + [400, "close"], + ); + const error = failure(await within(watched.settled)); + ok(isAborted(error)); + deepStrictEqual((error as Error).cause, streamError); + req.destroy(); + } finally { + await server.close(); + } + }, + ); }); diff --git a/packages/body/test/codings.test.ts b/packages/body/test/codings.test.ts new file mode 100644 index 00000000..0dcaf377 --- /dev/null +++ b/packages/body/test/codings.test.ts @@ -0,0 +1,133 @@ +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import { + brotliCompressSync, + deflateSync, + gzipSync, + zstdCompressSync, +} from "node:zlib"; +import { ct_json, ct_text } from "binden"; + +import { BodyParser } from "../index.js"; +import { type IBody, type IReply, send, serve } from "./helpers.js"; + +/** Sends one request through the parser (then `echo`) */ +async function post( + parser: BodyParser, + body: IBody, + headers: Record, +): Promise { + const server = await serve(parser); + try { + return await send(server.url, body, headers); + } finally { + await server.close(); + } +} + +/** The headers of a text body with content codings */ +function text(coding: string): Record { + return { "Content-Type": ct_text, "Content-Encoding": coding }; +} + +const hello = "Hello World"; +const parsed = { type: "text", value: hello }; + +suite("BodyParser (content codings)", () => { + test("gzip, x-gzip, deflate and br", async () => { + const parser = new BodyParser(); + const bodies: [string, Buffer][] = [ + ["gzip", gzipSync(hello)], + ["x-gzip", gzipSync(hello)], + ["GZIP", gzipSync(hello)], + ["deflate", deflateSync(hello)], + ["br", brotliCompressSync(hello)], + // Empty list elements are ignored + [" , br", brotliCompressSync(hello)], + ]; + for (const [coding, body] of bodies) { + deepStrictEqual(await post(parser, body, text(coding)), { + status: 200, + connection: "keep-alive", + body: parsed, + }); + } + }); + + test("two codings, removed in the reverse order", async () => { + const parser = new BodyParser(); + const body = brotliCompressSync(gzipSync(hello)); + deepStrictEqual((await post(parser, body, text("gzip, br"))).body, parsed); + const other = gzipSync(deflateSync(hello)); + deepStrictEqual( + (await post(parser, other, text("deflate, gzip"))).body, + parsed, + ); + }); + + test("more than two codings are a 415", async () => { + const parser = new BodyParser(); + const body = gzipSync(gzipSync(gzipSync(hello))); + deepStrictEqual(await post(parser, body, text("gzip, gzip, gzip")), { + status: 415, + connection: "close", + body: null, + }); + }); + + test("unknown and unsupported codings are a 415", async () => { + const parser = new BodyParser(); + for (const coding of ["foo", "gzip, foo", "identity", "compress", "zstd"]) { + const body = coding === "zstd" ? zstdCompressSync(hello) : hello; + deepStrictEqual( + await post(parser, body, text(coding)), + { status: 415, connection: "close", body: null }, + coding, + ); + } + }); + + test("corrupt data is a 400", async () => { + const parser = new BodyParser(); + const truncated = gzipSync(hello).subarray(0, 12); + for (const [coding, body] of [ + ["br", Buffer.from(hello)], + ["gzip", truncated], + ["deflate", Buffer.from(hello)], + ] as const) { + deepStrictEqual( + (await post(parser, body, text(coding))).status, + 400, + coding, + ); + } + }); + + test("the codings of a body that is not selected are not checked", async () => { + const parser = new BodyParser(); + deepStrictEqual( + ( + await post(parser, hello, { + "Content-Type": "application/x-unknown", + "Content-Encoding": "foo, bar, baz", + }) + ).body, + { type: "undefined" }, + ); + }); + + test("JSON with codings", async () => { + const parser = new BodyParser(); + const body = brotliCompressSync(deflateSync('{"a":1}')); + ok(body.byteLength > 0); + deepStrictEqual( + ( + await post(parser, body, { + "Content-Type": ct_json, + "Content-Encoding": "deflate, br", + }) + ).body, + { type: "json", value: { a: 1 } }, + ); + }); +}); diff --git a/packages/body/test/fixtures/late-rejections.ts b/packages/body/test/fixtures/late-rejections.ts new file mode 100644 index 00000000..3f2b3596 --- /dev/null +++ b/packages/body/test/fixtures/late-rejections.ts @@ -0,0 +1,69 @@ +// Run by `process.test.ts`: the late rejections of `parse` and of a `type` +// function must not crash the process (`unhandledRejection`), and the parser +// must not keep a request without a reply +import { type IncomingMessage, request } from "node:http"; +import type { AddressInfo } from "node:net"; +import { once } from "node:events"; +import { Binden } from "binden"; + +import { BodyParser } from "../../index.js"; + +process.on("unhandledRejection", () => { + process.stderr.write("unhandledRejection\n"); + process.exitCode = 1; +}); +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const sleep = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +/** Rejects once the deadline has passed */ +const late = async (): Promise => { + await sleep(80); + throw new Error("A late failure"); +}; + +const app = new Binden({ timeout: 30 }) + .use("/parse", new BodyParser({ parse: late })) + .use( + "/type", + new BodyParser({ + type: (): "json" => + Promise.reject(new Error("A failure")) as unknown as "json", + }), + ); + +const server = app.createServer().listen(0); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const statuses: number[] = []; +for (const path of ["/parse", "/type"]) { + const response = await new Promise((resolve, reject) => { + request( + { + agent: false, + path, + port, + method: "POST", + headers: { "Content-Type": "application/json" }, + }, + resolve, + ) + .on("error", reject) + .end("[]"); + }); + response.resume(); + await once(response, "end"); + statuses.push(response.statusCode ?? 0); +} + +// The late failures come after the replies +await sleep(150); +server.close(); +process.stdout.write(`${statuses.join(" ")}\n`); diff --git a/packages/body/test/helpers.ts b/packages/body/test/helpers.ts new file mode 100644 index 00000000..6b503d1c --- /dev/null +++ b/packages/body/test/helpers.ts @@ -0,0 +1,183 @@ +import { once } from "node:events"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { Readable } from "node:stream"; +import { Binden, type Context, type IMiddleware } from "binden"; +import type { ILogger } from "@binden/logger"; +import { Agent, request } from "undici"; + +/** A server of the tests */ +export interface IServer { + readonly url: string; + readonly port: number; + close(): Promise; +} + +/** What a reply of `echo` tells of `request.body` */ +export interface ISummary { + type: "buffer" | "form" | "json" | "text" | "undefined"; + value?: unknown; +} + +/** `request.body` as JSON: its type and its value */ +export function summarize(body: unknown): ISummary { + if (typeof body === "undefined") { + return { type: "undefined" }; + } else if (Buffer.isBuffer(body)) { + return { type: "buffer", value: body.toString("hex") }; + } else if (body instanceof URLSearchParams) { + return { type: "form", value: [...body] }; + } else if (typeof body === "string") { + return { type: "text", value: body }; + } + return { type: "json", value: body }; +} + +/** Replies with the summary of `request.body` */ +export function echo(context: Context): Promise { + return context.json({ ...summarize(context.request.body) }); +} + +/** Listens with an app (closing every connection on `close()`) */ +export async function listen(app: Binden): Promise { + const server: Server = app.createServer().listen(0); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + return { + url: `http://localhost:${port}/`, + port, + close: async (): Promise => { + server.closeAllConnections(); + await new Promise((resolve) => { + server.close(resolve); + }); + }, + }; +} + +/** Serves the middlewares, then `echo` */ +export function serve(...middlewares: IMiddleware[]): Promise { + return listen(new Binden().use(...middlewares, echo)); +} + +/** A reply: its status, its `Connection` header and its body (as JSON) */ +export interface IReply { + status: number; + connection: unknown; + body: unknown; +} + +/** The body of a request: a chunked one when it is a stream */ +export type IBody = Buffer | Readable | string; + +/** + * Sends a request (`POST` by default) within 5 seconds, on a connection that + * the client would keep: the `Connection` of the reply is the choice of the + * server + */ +export async function send( + url: string, + body: IBody | null, + headers: Record = {}, + method = "POST", +): Promise { + const dispatcher = new Agent(); + try { + const response = await request(url, { + method, + headers, + body, + dispatcher, + signal: AbortSignal.timeout(5000), + }); + const text = await response.body.text(); + return { + status: response.statusCode, + connection: response.headers.connection, + body: text === "" ? null : (JSON.parse(text) as unknown), + }; + } finally { + await dispatcher.destroy(); + } +} + +/** The chunks of a body, sent one at a time (as a chunked body) */ +export function chunked(...chunks: Buffer[]): Readable { + return Readable.from(chunks, { objectMode: false }); +} + +/** Settles after `ms` milliseconds */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** The value of a promise, or a failure once `ms` milliseconds have passed */ +export async function within(promise: Promise, ms = 5000): Promise { + const signal = AbortSignal.timeout(ms); + const { promise: expired, reject } = Promise.withResolvers(); + const expire = (): void => { + reject(new Error(`Not settled within ${ms} ms`)); + }; + signal.addEventListener("abort", expire, { once: true }); + try { + return await Promise.race([promise, expired]); + } finally { + signal.removeEventListener("abort", expire); + } +} + +/** Never settles */ +export function never(): Promise { + return new Promise(() => { + // Never settles + }); +} + +/** The levels of a logger */ +export type ILevel = "debug" | "error" | "fatal" | "info" | "trace" | "warn"; + +/** A logger that keeps everything that it gets, as JSON */ +export class Recorder implements ILogger { + public readonly lines: [ILevel, string, string][] = []; + + public fatal(message: string, extra?: Record): void { + this.#log("fatal", message, extra); + } + + public error(message: string, extra?: Record): void { + this.#log("error", message, extra); + } + + public warn(message: string, extra?: Record): void { + this.#log("warn", message, extra); + } + + public info(message: string, extra?: Record): void { + this.#log("info", message, extra); + } + + public debug(message: string, extra?: Record): void { + this.#log("debug", message, extra); + } + + public trace(message: string, extra?: Record): void { + this.#log("trace", message, extra); + } + + public child(): ILogger { + return this; + } + + /** The messages of a level */ + public messages(level: ILevel): string[] { + return this.lines + .filter(([lineLevel]) => lineLevel === level) + .map(([, message]) => message); + } + + #log(level: ILevel, message: string, extra?: Record): void { + this.lines.push([level, message, JSON.stringify(extra ?? {})]); + } +} diff --git a/packages/body/test/index.test.ts b/packages/body/test/index.test.ts index 956cae1b..e1918561 100644 --- a/packages/body/test/index.test.ts +++ b/packages/body/test/index.test.ts @@ -1,432 +1,573 @@ -/* eslint-disable init-declarations */ -import { deepEqual, ok, fail, throws } from "node:assert/strict"; -import { Server } from "node:http"; -import type { AddressInfo } from "node:net"; -import { afterEach, beforeEach, suite, test } from "node:test"; -import { - brotliCompress as brotliCompressAsync, - gzip as gzipAsync, - deflate as deflateAsync, - gzipSync, - type InputType, -} from "node:zlib"; +import { deepStrictEqual, ok } from "node:assert/strict"; +import { once } from "node:events"; +import { suite, test } from "node:test"; import { Ajv as AJV, type JTDDataType } from "ajv/dist/jtd.js"; -import { Agent, errors, request } from "undici"; -import { Binden, Context, ct_text, ct_json, ct_form } from "binden"; +import { + BindenError, + type Context, + type ContentType, + ct_form, + ct_json, + ct_text, +} from "binden"; import { BodyParser, type IParse } from "../index.js"; - -function brotliCompress(data: InputType): Promise { - return new Promise((resolve, reject) => { - brotliCompressAsync(data, (error, result) => { - if (error) { - reject(error); - } else { - resolve(result); - } - }); - }); +import { + type IBody, + type IReply, + chunked, + send, + serve, + summarize, +} from "./helpers.js"; + +/** Sends one request through the middlewares (then `echo`) */ +async function post( + middlewares: Parameters, + body: IBody | null, + headers: Record = {}, + method = "POST", +): Promise { + const server = await serve(...middlewares); + try { + return await send(server.url, body, headers, method); + } finally { + await server.close(); + } } -function gzip(data: InputType): Promise { - return new Promise((resolve, reject) => { - gzipAsync(data, (error, result) => { - if (error) { - reject(error); - } else { - resolve(result); - } +const json = { "Content-Type": ct_json }; + +suite("BodyParser", () => { + test("JSON: objects, arrays and `+json` types", async () => { + const parser = new BodyParser(); + const reply = (value: unknown): IReply => ({ + status: 200, + connection: "keep-alive", + body: { type: "json", value }, }); + + deepStrictEqual(await post([parser], '{"a":1}', json), reply({ a: 1 })); + deepStrictEqual(await post([parser], "[1,2]", json), reply([1, 2])); + deepStrictEqual( + await post([parser], ' \r\n\t{"a":[]}', { + "Content-Type": "application/vnd.api+json; charset=UTF-8", + }), + reply({ a: [] }), + ); }); -} -function deflate(data: InputType): Promise { - return new Promise((resolve, reject) => { - deflateAsync(data, (error, result) => { - if (error) { - reject(error); - } else { - resolve(result); - } + test("JSON: an empty body has no value", async () => { + deepStrictEqual(await post([new BodyParser()], "", json), { + status: 200, + connection: "keep-alive", + body: { type: "undefined" }, }); }); -} -let url = ""; + test("JSON: invalid, or only whitespace, is a 400", async () => { + const parser = new BodyParser({ strict: false }); + for (const body of ["Not a JSON", "{", " \r\n\t", '{"a":1} x']) { + deepStrictEqual((await post([parser], body, json)).status, 400); + } + }); -suite("BodyParser", () => { - let app: Binden; - let server: Server; - - beforeEach((_t, done) => { - app = new Binden(); - server = app.createServer().listen(0, () => { - url = `http://localhost:${(server.address() as AddressInfo).port}/`; - done(); - }); + test("JSON: `strict` accepts only an object or an array", async () => { + const strict = new BodyParser(); + const loose = new BodyParser({ strict: false }); + for (const [body, value] of [ + ['"text"', "text"], + ["1", 1], + ["null", null], + [" true", true], + ] as const) { + deepStrictEqual((await post([strict], body, json)).status, 400); + deepStrictEqual((await post([loose], body, json)).body, summarize(value)); + } }); - test("constructor (with an invalid `parse` option)", () => { - throws( - () => new BodyParser({ parse: 2 as unknown as IParse }), - new TypeError("`parse` is not a function"), - ); + test("JSON: `JSON.parse` is the default parse", async () => { + const parser = new BodyParser({ parse: JSON.parse }); + // `strict` applies, and a `SyntaxError` is the fault of the client + deepStrictEqual((await post([parser], '"text"', json)).status, 400); + deepStrictEqual((await post([parser], "{", json)).status, 400); + deepStrictEqual((await post([parser], "[]", json)).body, { + type: "json", + value: [], + }); }); - test("constructor (with an invalid `limit` option)", () => { - for (const limit of [-1, 1.5, Number.NaN, "1" as unknown as number]) { - throws( - () => new BodyParser({ limit }), - new TypeError("`limit` is not a non-negative integer"), + test("JSON: a custom `parse` (synchronous or not)", async () => { + const ajv = new AJV(); + const schema = { + properties: { username: { type: "string" } }, + additionalProperties: false, + } as const; + const ajvParse = ajv.compileParser>(schema); + const parsers: [string, IParse][] = [ + ["ajv", ajvParse], + ["async", (input): Promise => Promise.resolve(ajvParse(input))], + ]; + for (const [name, parse] of parsers) { + const parser = new BodyParser({ parse }); + const valid = await post([parser], '{"username":"a"}', json); + deepStrictEqual( + valid.body, + { + type: "json", + value: { username: "a" }, + }, + name, ); + // `undefined` tells that the body is invalid + deepStrictEqual((await post([parser], '{"x":1}', json)).status, 400); + // Not `strict` + deepStrictEqual((await post([parser], '"a"', json)).status, 400); } + const primitive = new BodyParser({ + parse: (input): number => input.length, + }); + deepStrictEqual((await post([primitive], '"a"', json)).body, { + type: "json", + value: 3, + }); }); - test("`limit` (the size of the decoded body)", async () => { - const received: unknown[] = []; - app.use(new BodyParser({ limit: 1024 }), (ct: Context) => { - received.push(ct.request.body); - return ct.send(); + test("JSON: the message of a parse that returns `undefined`", async () => { + const ajv = new AJV(); + const parse = ajv.compileParser({ properties: { a: { type: "string" } } }); + const errors: unknown[] = []; + const capture = async (context: Context): Promise => { + try { + await new BodyParser({ parse }).run(context); + } catch (error: unknown) { + errors.push(error); + throw error; + } + }; + deepStrictEqual((await post([capture], '{"a":1}', json)).status, 400); + const [error] = errors; + ok(error instanceof BindenError); + deepStrictEqual(error.message, parse.message); + ok(typeof parse.message === "string" && parse.message !== ""); + + // Without a message + const undefinedParse = new BodyParser({ + parse: (): undefined => Reflect.get({}, "nothing") as undefined, }); - // Keeps its connections (unlike the global dispatcher of the tests) - const dispatcher = new Agent(); - const send = async ( - body: Buffer | string, - headers: Record = {}, - ): Promise<[number, unknown]> => { - const response = await request(url, { - method: "POST", - body, - dispatcher, - headers: { "Content-Type": ct_text, ...headers }, - }); - await response.body.text(); - return [response.statusCode, response.headers.connection]; + const without: unknown[] = []; + const run = async (context: Context): Promise => { + try { + await undefinedParse.run(context); + } catch (failure: unknown) { + without.push(failure); + throw failure; + } }; - - deepEqual(await send("a".repeat(1024)), [200, "keep-alive"]); - // Not read further: the connection is closed after the reply - deepEqual(await send("a".repeat(1025)), [413, "close"]); - // A small compressed body that decodes to more than the limit - const bomb = gzipSync(Buffer.alloc(64 * 1024)); - ok(bomb.byteLength < 1024); - deepEqual(await send(bomb, { "Content-Encoding": "gzip" }), [413, "close"]); - deepEqual(received, ["a".repeat(1024)]); - await dispatcher.close(); + deepStrictEqual((await post([run], "{}", json)).status, 400); + deepStrictEqual((without[0] as Error).message, "Bad Request"); }); - test("text", async () => { - const body = "Hello World"; - const assert = (ct: Context): Promise => { - deepEqual(ct.request.body, body); - return ct.send(); + test("JSON: the failures of a custom `parse`", async () => { + const unprocessable = (): never => { + throw new BindenError(422); }; - - app.use(new BodyParser(), assert); - - const headers = { "Content-Type": ct_text }; - const response = await request(url, { method: "POST", body, headers }); - ok(response.statusCode === 200); - const text = await response.body.text(); - deepEqual(text, ""); + const nothing = (): never => { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- any value + throw null; + }; + const statuses: [IParse, number][] = [ + [unprocessable, 422], + [(): Promise => Promise.reject(new BindenError(409)), 409], + // A `SyntaxError` of a custom parse + [(input): unknown => JSON.parse(`${input}x`), 500], + [(): Promise => Promise.reject(new TypeError("A failure")), 500], + [nothing, 500], + ]; + for (const [parse, status] of statuses) { + const parser = new BodyParser({ parse }); + deepStrictEqual((await post([parser], "{}", json)).status, status); + } }); - test("charset", async () => { - const received: unknown[] = []; - app.use(new BodyParser(), (ct: Context) => { - received.push(ct.request.body); - return ct.send(); - }); - const send = async (charset: string, body: Buffer): Promise => { - const headers = { "Content-Type": `${ct_text}; charset=${charset}` }; - const response = await request(url, { method: "POST", body, headers }); - await response.body.text(); - return response.statusCode; + test("JSON: `parse` is called only for a body that is not empty or blank", async () => { + const inputs: string[] = []; + const parse = (input: string): { input: string } => { + inputs.push(input); + return { input }; }; + const parser = new BodyParser({ parse }); + deepStrictEqual((await post([parser], "", json)).body, { + type: "undefined", + }); + deepStrictEqual((await post([parser], " \n", json)).status, 400); + deepStrictEqual((await post([parser], " x", json)).body, { + type: "json", + value: { input: " x" }, + }); + deepStrictEqual(inputs, [" x"]); + }); - // `café` - deepEqual( - await send("iso-8859-1", Buffer.from([0x63, 0x61, 0x66, 0xe9])), - 200, + test("text", async () => { + const parser = new BodyParser(); + deepStrictEqual( + await post([parser], "Hello World", { "Content-Type": ct_text }), + { + status: 200, + connection: "keep-alive", + body: { type: "text", value: "Hello World" }, + }, + ); + deepStrictEqual( + (await post([parser], "", { "Content-Type": ct_text })).body, + { + type: "text", + value: "", + }, + ); + // Not selected by default + deepStrictEqual( + (await post([parser], "

", { "Content-Type": "text/html" })).body, + { + type: "undefined", + }, ); - deepEqual(await send("utf-8", Buffer.from("café")), 200); - deepEqual(await send("unknown-charset", Buffer.from("café")), 415); - deepEqual(received, ["café", "café"]); }); - test("Unsupported methods", async () => { - const assert = (ct: Context): Promise => { - ok(typeof ct.request.body === "undefined"); - return ct.send(); - }; - - app.use(new BodyParser(), assert); - - const { unsupported_methods } = BodyParser; - // `CONNECT` is not supported, if any - unsupported_methods.delete("CONNECT"); - - for (const method of unsupported_methods) { - const headers = { "Content-Type": ct_text }; - const response = await request(url, { - method: method as "GET", - headers, + test("charset", async () => { + const parser = new BodyParser(); + const text = (charset: string, body: Buffer): Promise => + post([parser], body, { + "Content-Type": `${ct_text}; charset=${charset}`, }); - ok(response.statusCode === 200); - const text = await response.body.text(); - deepEqual(text, ""); - } - }); - test("Destroyed socket", async () => { - const promise = new Promise((resolve) => { - const destroy = (ct: Context): void => { - ct.request.destroy(); - deepEqual(ct.request.destroyed, true); - }; - const assert = (ct: Context): Promise => { - ok(typeof ct.request.body === "undefined"); - resolve(); - return ct.send(); - }; - - app.use(destroy, new BodyParser(), assert); + deepStrictEqual( + (await text("iso-8859-1", Buffer.from([0x63, 0x61, 0x66, 0xe9]))).body, + { type: "text", value: "café" }, + ); + deepStrictEqual( + (await text("UTF-16LE", Buffer.from("café", "utf16le"))).body, + { type: "text", value: "café" }, + ); + deepStrictEqual((await text("utf-8", Buffer.from("café"))).body, { + type: "text", + value: "café", + }); + // Unknown: not read + deepStrictEqual(await text("unknown-charset", Buffer.from("café")), { + status: 415, + connection: "close", + body: null, }); + // Invalid bytes: read + deepStrictEqual(await text("utf-8", Buffer.from([0x63, 0xff])), { + status: 400, + connection: "keep-alive", + body: null, + }); + deepStrictEqual( + (await post([parser], Buffer.from([0x7b, 0xff, 0x7d]), json)).status, + 400, + ); + }); - const body = "Hello World"; - const headers = { "Content-Type": ct_text }; - try { - await request(url, { method: "POST", body, headers }); - fail("Should throw an Error"); - } catch (error: unknown) { - ok(error instanceof errors.SocketError); - deepEqual(error.message, `other side closed`); - deepEqual(error.code, `UND_ERR_SOCKET`); - } - await promise; + test("form", async () => { + const parser = new BodyParser(); + const form = (body: string): Promise => + post([parser], body, { "Content-Type": ct_form }); + deepStrictEqual((await form("a=1&b=%20x+y&a=2")).body, { + type: "form", + value: [ + ["a", "1"], + ["b", " x y"], + ["a", "2"], + ], + }); + deepStrictEqual((await form("")).body, { type: "form", value: [] }); }); - test("JSON", async () => { - const expected = { message: "Hello World" }; - const assert = (ct: Context): Promise => { - deepEqual(ct.request.body, expected); - return ct.send(); - }; + test("`parameter_limit`", async () => { + const form = { "Content-Type": ct_form }; + const parameters = (count: number): string => + Array.from({ length: count }, (_, index) => `p${index}=${index}`).join( + "&", + ); - app.use(new BodyParser(), assert); + // 1000 by default + const parser = new BodyParser(); + deepStrictEqual((await post([parser], parameters(1000), form)).status, 200); + deepStrictEqual(await post([parser], parameters(1001), form), { + status: 413, + connection: "keep-alive", + body: null, + }); - const headers = { "Content-Type": ct_json }; - const body = JSON.stringify(expected); - const response = await request(url, { method: "POST", body, headers }); - ok(response.statusCode === 200); - const text = await response.body.text(); - deepEqual(text, ""); - }); + const two = new BodyParser({ parameter_limit: 2 }); + // Empty parameters are not counted + deepStrictEqual((await post([two], "&a=1&&b=2&", form)).body, { + type: "form", + value: [ + ["a", "1"], + ["b", "2"], + ], + }); + deepStrictEqual((await post([two], "a=1&b=2&c", form)).status, 413); + deepStrictEqual((await post([two], "a", form)).status, 200); - test("JSON (with a custom `parse`)", async () => { - const ajv = new AJV(); - const schema = { - properties: { - username: { type: "string" }, - password: { type: "string" }, - }, - additionalProperties: false, - }; - const parse: IParse> = - ajv.compileParser>(schema); - const body_parser = new BodyParser({ parse }); - - const expected = { username: "someusername", password: "somepassword" }; - const assert = (ct: Context): Promise => { - deepEqual(ct.request.body, expected); - return ct.send(); - }; + const none = new BodyParser({ parameter_limit: 0 }); + deepStrictEqual((await post([none], "", form)).status, 200); + deepStrictEqual((await post([none], "&", form)).status, 200); + deepStrictEqual((await post([none], "a", form)).status, 413); - app.use(body_parser, assert); + const unlimited = new BodyParser({ parameter_limit: Infinity }); + deepStrictEqual( + (await post([unlimited], parameters(1001), form)).status, + 200, + ); + }); - const headers = { "Content-Type": ct_json }; - const body = JSON.stringify(expected); - const response = await request(url, { method: "POST", body, headers }); - ok(response.statusCode === 200); - const text = await response.body.text(); - deepEqual(text, ""); + test("`raw`", async () => { + const octet = { "Content-Type": "application/octet-stream" }; + const raw = new BodyParser({ raw: true }); + deepStrictEqual(await post([raw], Buffer.from([0, 0xff]), octet), { + status: 200, + connection: "keep-alive", + body: { type: "buffer", value: "00ff" }, + }); + deepStrictEqual((await post([raw], "", octet)).body, { + type: "buffer", + value: "", + }); + // A charset does not apply to bytes + deepStrictEqual( + ( + await post([raw], "a", { + "Content-Type": "application/octet-stream; charset=unknown", + }) + ).body, + { type: "buffer", value: "61" }, + ); + // The other types still are parsed + deepStrictEqual((await post([raw], "[]", json)).body, { + type: "json", + value: [], + }); + // Not without `raw` + deepStrictEqual((await post([new BodyParser()], "a", octet)).body, { + type: "undefined", + }); + // With a list + const listed = new BodyParser({ raw: true, type: ["text/plain"] }); + deepStrictEqual((await post([listed], "a", octet)).body, { + type: "buffer", + value: "61", + }); + deepStrictEqual((await post([listed], "[]", json)).body, { + type: "undefined", + }); }); - test("JSON (a custom `parse` that cannot parse the body)", async () => { - const ajv = new AJV(); - const schema = { - properties: { username: { type: "string" } }, - additionalProperties: false, - } as const; - const parsers: [string, IParse][] = [ - ["/ajv", ajv.compileParser>(schema)], - // Returns `undefined` - ["/undefined", (): unknown => Reflect.get({}, "nothing")], + test("`type`: a list of media types", async () => { + const all = new BodyParser({ type: ["*/*"] }); + const kinds: [string, unknown][] = [ + [ct_json, { type: "json", value: [] }], + ["application/ld+json", { type: "json", value: [] }], + [ct_form, { type: "form", value: [["[]", ""]] }], + ["text/csv", { type: "text", value: "[]" }], + ["application/xml", { type: "buffer", value: "5b5d" }], ]; - const received: unknown[] = []; - for (const [path, parse] of parsers) { - app.use(path, new BodyParser({ parse }), (ct: Context) => { - received.push(ct.request.body); - return ct.send(); - }); + for (const [type, summary] of kinds) { + deepStrictEqual( + (await post([all], "[]", { "Content-Type": type })).body, + summary, + type, + ); } - const send = async (path: string, body: string): Promise => { - const headers = { "Content-Type": ct_json }; - const response = await request(new URL(path, url), { - method: "POST", - body, - headers, - }); - await response.body.text(); - return response.statusCode; - }; - - // `undefined` is a failure, which `ajv` explains in its `message` - deepEqual(await send("/ajv", `{"username":1}`), 415); - deepEqual(await send("/ajv", `{"x":1}`), 415); - deepEqual(await send("/ajv", `{"username":"a"}`), 200); - deepEqual(await send("/undefined", "{}"), 415); - deepEqual(received, [{ username: "a" }]); - }); - - test("JSON (invalid)", async () => { - app.use(new BodyParser()); - const headers = { "Content-Type": ct_json }; - const body = "Not a JSON"; - const response = await request(url, { method: "POST", body, headers }); - deepEqual(response.statusCode, 415); - const text = await response.body.text(); - deepEqual(text, ""); + const selected: [string[], string, boolean][] = [ + [["text/*"], "text/html", true], + [["text/*"], "application/json", false], + [["TEXT/HTML"], "text/html", true], + [["text/html"], "text/plain", false], + [["*/html"], "text/html", true], + [["+json"], "application/geo+json", true], + [["+json"], "application/json", false], + [["+json"], "application/+json", false], + [[], "text/plain", false], + ]; + for (const [type, content_type, parsed] of selected) { + const reply = await post([new BodyParser({ type })], "[1]", { + "Content-Type": content_type, + }); + deepStrictEqual( + reply.body !== null && + (reply.body as { type: string }).type !== "undefined", + parsed, + `${type.join()} ${content_type}`, + ); + } }); - test("form", async () => { - const body = new URLSearchParams({ message: "Hello World" }); - const assert = (ct: Context): Promise => { - deepEqual(ct.request.body, body); - return ct.send(); - }; - - app.use(new BodyParser(), assert); + test("`type`: a function of the `Content-Type`", async () => { + const received: ContentType[] = []; + const parser = new BodyParser({ + type: (content_type): "json" | null => { + received.push(content_type); + return content_type.type === "application/csp-report" ? "json" : null; + }, + }); + deepStrictEqual( + ( + await post([parser], '{"csp-report":{}}', { + "Content-Type": "application/csp-report; charset=utf-8", + }) + ).body, + { type: "json", value: { "csp-report": {} } }, + ); + deepStrictEqual((await post([parser], "[]", json)).body, { + type: "undefined", + }); + deepStrictEqual( + received.map((content_type) => content_type.toString()), + ["application/csp-report; charset=utf-8", ct_json], + ); - const headers = { "Content-Type": ct_form }; - const response = await request(url, { - method: "POST", - headers, - body: body.toString(), + // Not called without a `Content-Type` + deepStrictEqual((await post([parser], "[]", {})).body, { + type: "undefined", }); - ok(response.statusCode === 200); - const text = await response.body.text(); - deepEqual(text, ""); + deepStrictEqual(received.length, 2); }); - test("encoding", async () => { - const expected = { message: "Hello World" }; - const assert = (ct: Context): Promise => { - deepEqual(ct.request.body, expected); - return ct.send(); - }; - - app.use(new BodyParser(), assert); - - const brotli = await brotliCompress(JSON.stringify(expected)); - const doubleGzip = await gzip(await gzip(brotli)); - const body = await deflate(doubleGzip); - - const ce = "br, x-gzip, gzip, deflate"; - const headers = { "Content-Encoding": ce, "Content-Type": ct_json }; - const response = await request(url, { method: "POST", body, headers }); - ok(response.statusCode); - const text = await response.body.text(); - deepEqual(text, ""); + test("`type`: a function that fails is a 500", async () => { + const failures: [string, BodyParser][] = [ + [ + "throws", + new BodyParser({ + type: (): never => { + throw new Error("A failure"); + }, + }), + ], + [ + "throws an HTTP error", + new BodyParser({ + type: (): never => { + throw new BindenError(404); + }, + }), + ], + ["invalid", new BodyParser({ type: () => "xml" as "raw" })], + [ + "promise", + new BodyParser({ + type: () => Promise.resolve("raw") as unknown as "raw", + }), + ], + ]; + for (const [name, parser] of failures) { + deepStrictEqual((await post([parser], "[]", json)).status, 500, name); + } }); - test("Missing `Content-Type`", async () => { - const body = "Hello World"; - const assert = (ct: Context): Promise => { - ok(typeof ct.request.body === "undefined"); - return ct.send(); + test("a body that is not selected is not read", async () => { + const bodies: string[] = []; + const read = async (context: Context): Promise => { + bodies.push( + await context.request + .setEncoding("utf8") + .toArray() + .then((chunks) => chunks.join("")), + ); }; - - app.use(new BodyParser(), assert); - - const headers = { "Content-Type": "" }; - - const response = await request(url, { method: "POST", body, headers }); - deepEqual(response.statusCode, 200); - const text = await response.body.text(); - deepEqual(text, ""); + for (const headers of [ + { "Content-Type": "application/x-unknown" }, + { "Content-Type": "multipart/form-data; boundary=x" }, + // Without a `Content-Type`, or a malformed one + {}, + { "Content-Type": "json" }, + ]) { + deepStrictEqual( + (await post([new BodyParser(), read], "Hello World", headers)).body, + { type: "undefined" }, + ); + } + deepStrictEqual( + bodies, + Array.from({ length: 4 }, () => "Hello World"), + ); }); - test("Unsupported `Content-Type`", async () => { - const body = "Hello World"; - const assert = (ct: Context): Promise => { - ok(typeof ct.request.body === "undefined"); - return ct.send(); - }; - - app.use(new BodyParser(), assert); + test("a body that is not selected is left to another parser", async () => { + const first = new BodyParser(); + const second = new BodyParser({ type: ["text/csv"] }); + deepStrictEqual( + (await post([first, second], "a,b", { "Content-Type": "text/csv" })).body, + { type: "text", value: "a,b" }, + ); + }); - const headers = { "Content-Type": "__unsupported__" }; - const response = await request(url, { method: "POST", body, headers }); - deepEqual(response.statusCode, 200); - const text = await response.body.text(); - deepEqual(text, ""); + test("the methods without a body are skipped", async () => { + const methods = [...BodyParser.unsupported_methods]; + for (const method of methods) { + // Even with an (invalid) body + const reply = await post([new BodyParser()], "{x", json, method); + deepStrictEqual(reply.status, 200, method); + deepStrictEqual( + reply.body, + method === "HEAD" ? null : { type: "undefined" }, + ); + } }); - test("`Content-Encoding` (case-insensitive, unknown codings)", async () => { - let parsed: unknown = null; - app.use(new BodyParser(), async (context: Context): Promise => { - parsed = context.request.body; - await context.send(); + test("a body is parsed once", async () => { + const inputs: string[] = []; + const parser = new BodyParser({ + parse: (input): unknown => { + inputs.push(input); + return JSON.parse(input) as unknown; + }, }); - const body = gzipSync("Hello World"); - - const upper = await request(url, { - method: "POST", - body, - headers: { "Content-Type": ct_text, "Content-Encoding": "GZIP" }, + const other = new BodyParser(); + deepStrictEqual((await post([parser, other, parser], "[1]", json)).body, { + type: "json", + value: [1], }); - deepEqual(upper.statusCode, 200); - await upper.body.dump(); - deepEqual(parsed, "Hello World"); - - // An unknown coding makes the body undecodable - const unknown = await request(url, { - method: "POST", - body, - headers: { "Content-Type": ct_text, "Content-Encoding": "gzip, foo" }, + // An empty body (without a value) is not read again either + deepStrictEqual((await post([parser, other], "", json)).body, { + type: "undefined", }); - deepEqual(unknown.statusCode, 415); - await unknown.body.dump(); + deepStrictEqual(inputs, ["[1]"]); }); - test("Unsupported `Content-Encoding`", async () => { - const body = "Hello World"; - - app.use(new BodyParser()); - - const headers = { "Content-Type": ct_json, "Content-Encoding": "compress" }; - const response = await request(url, { method: "POST", body, headers }); - deepEqual(response.statusCode, 415); - const text = await response.body.text(); - deepEqual(text, ""); - }); - - test("Decompressions errors", async () => { - const body = "Hello World"; - - app.use(new BodyParser()); - - const headers = { "Content-Type": ct_text, "Content-Encoding": "br" }; - const response = await request(url, { method: "POST", body, headers }); - deepEqual(response.statusCode, 415); - const text = await response.body.text(); - deepEqual(text, ""); - }); - - afterEach((_t, done) => { - server.closeIdleConnections(); - server.close(done); + test("a body that something else has read is a 500", async () => { + const read = async (context: Context): Promise => { + await context.request.toArray(); + }; + const resume = async (context: Context): Promise => { + context.request.resume(); + await once(context.request, "end"); + }; + deepStrictEqual( + (await post([read, new BodyParser()], "[]", json)).status, + 500, + ); + // Empty: ended without data + deepStrictEqual( + (await post([resume, new BodyParser()], "", json)).status, + 500, + ); + // Partly + const first = async (context: Context): Promise => { + const chunks = context.request.iterator({ destroyOnReturn: false }); + await chunks.next(); + await chunks.return?.(); + }; + const body = chunked(Buffer.from("a"), Buffer.alloc(64 * 1024, 0x62)); + const text = { "Content-Type": ct_text }; + deepStrictEqual( + (await post([first, new BodyParser()], body, text)).status, + 500, + ); }); }); diff --git a/packages/body/test/limits.test.ts b/packages/body/test/limits.test.ts new file mode 100644 index 00000000..13e8cf8a --- /dev/null +++ b/packages/body/test/limits.test.ts @@ -0,0 +1,241 @@ +import { deepStrictEqual, ok } from "node:assert/strict"; +import { once } from "node:events"; +import { + type IncomingMessage, + type OutgoingHttpHeaders, + request, +} from "node:http"; +import { randomBytes } from "node:crypto"; +import { suite, test } from "node:test"; +import { brotliCompressSync, gzipSync } from "node:zlib"; +import { ct_text } from "binden"; + +import { BodyParser } from "../index.js"; +import { type IBody, type IReply, chunked, send, serve } from "./helpers.js"; + +/** Sends one request through the parser (then `echo`) */ +async function post( + parser: BodyParser, + body: IBody, + headers: Record = {}, +): Promise { + const server = await serve(parser); + try { + return await send(server.url, body, { + "Content-Type": ct_text, + ...headers, + }); + } finally { + await server.close(); + } +} + +/** + * Sends the headers of a request and the first chunks of its body (if any), + * without ending it: the reply that comes before the rest of the body + */ +async function partial( + parser: BodyParser, + headers: OutgoingHttpHeaders, + ...chunks: Buffer[] +): Promise<{ status: number | undefined; connection: unknown }> { + const server = await serve(parser); + const req = request({ + port: server.port, + method: "POST", + agent: false, + headers: { "Content-Type": ct_text, ...headers }, + }); + req.on("error", () => { + // The connection is closed after the reply + }); + try { + req.flushHeaders(); + for (const chunk of chunks) { + req.write(chunk); + } + const [response] = (await once(req, "response", { + signal: AbortSignal.timeout(3000), + })) as [IncomingMessage]; + response.resume(); + return { + status: response.statusCode, + connection: response.headers.connection, + }; + } finally { + req.destroy(); + await server.close(); + } +} + +/** + * A gzip member whose header has a comment of `size` bytes: much larger than + * the data it decodes to + */ +function commented(data: string, size: number): Buffer { + const member = gzipSync(data); + const header = Buffer.from(member.subarray(0, 10)); + // FCOMMENT (RFC 1952) + header.writeUInt8(0x10, 3); + const comment = Buffer.alloc(size + 1, 0x61); + comment.writeUInt8(0, size); + return Buffer.concat([header, comment, member.subarray(10)]); +} + +const hello = "Hello World"; + +const accepted = (value: string): IReply => ({ + status: 200, + connection: "keep-alive", + body: { type: "text", value }, +}); + +const refused = { status: 413, connection: "close", body: null }; + +suite("BodyParser (limits)", () => { + test("`limit`: the size of the body", async () => { + const parser = new BodyParser({ limit: 1024 }); + const fits = "a".repeat(1024); + deepStrictEqual(await post(parser, fits), accepted(fits)); + deepStrictEqual( + await post(parser, chunked(Buffer.from(fits))), + accepted(fits), + ); + // Counted while reading + deepStrictEqual( + await post( + parser, + chunked(Buffer.alloc(1000, 0x61), Buffer.alloc(25, 0x61)), + ), + refused, + ); + deepStrictEqual(await post(parser, "a".repeat(1025)), refused); + + const none = new BodyParser({ limit: 0 }); + deepStrictEqual(await post(none, ""), accepted("")); + deepStrictEqual(await post(none, "a"), refused); + }); + + test("no limit by default", async () => { + const large = "a".repeat(4 * 1024 * 1024); + for (const parser of [ + new BodyParser(), + new BodyParser({ limit: Infinity, encoded_limit: Infinity }), + ]) { + const { status, body } = await post(parser, gzipSync(large), { + "Content-Encoding": "gzip", + }); + deepStrictEqual(status, 200); + deepStrictEqual((body as { value: string }).value.length, large.length); + } + }); + + test( + "a larger `Content-Length` is refused before the body is read", + { timeout: 10_000 }, + async () => { + deepStrictEqual( + await partial(new BodyParser({ limit: 1024 }), { + "Content-Length": 1025, + }), + { status: 413, connection: "close" }, + ); + deepStrictEqual( + await partial(new BodyParser({ encoded_limit: 1024 }), { + "Content-Length": 1025, + "Content-Encoding": "gzip", + }), + { status: 413, connection: "close" }, + ); + }, + ); + + test("`limit` does not apply to the `Content-Length` of an encoded body", async () => { + const parser = new BodyParser({ limit: 10 }); + const body = gzipSync("a"); + ok(body.byteLength > 10); + deepStrictEqual( + await post(parser, body, { "Content-Encoding": "gzip" }), + accepted("a"), + ); + }); + + test("`encoded_limit`: the size of the body as received", async () => { + const parser = new BodyParser({ encoded_limit: 1024 }); + const noise = randomBytes(1024).toString("hex"); + const body = gzipSync(noise); + ok(body.byteLength > 1024); + const gzip = { "Content-Encoding": "gzip" }; + deepStrictEqual(await post(parser, body, gzip), refused); + // Counted while reading + deepStrictEqual(await post(parser, chunked(body), gzip), refused); + deepStrictEqual( + await post(parser, chunked(Buffer.alloc(1025, 0x61))), + refused, + ); + deepStrictEqual( + await post(parser, chunked(Buffer.alloc(1024, 0x61))), + accepted("a".repeat(1024)), + ); + // The decoded body may be larger + const zeros = "0".repeat(64 * 1024); + deepStrictEqual( + await post(parser, chunked(gzipSync(zeros)), gzip), + accepted(zeros), + ); + }); + + test("a small compressed body that decodes to more than `limit`", async () => { + const parser = new BodyParser({ limit: 1024 }); + const zeros = Buffer.alloc(64 * 1024); + for (const [coding, body] of [ + ["gzip", gzipSync(zeros)], + ["br", brotliCompressSync(zeros)], + ] as const) { + ok(body.byteLength < 1024); + deepStrictEqual( + await post(parser, chunked(body), { "Content-Encoding": coding }), + refused, + coding, + ); + } + }); + + test("`limit` applies to every stage of the decoding", async () => { + // The outer coding decodes to 1 MiB, the inner one to a few bytes + const body = gzipSync(commented(hello, 1024 * 1024)); + ok(body.byteLength < 64 * 1024); + const headers = { "Content-Encoding": "gzip, gzip" }; + deepStrictEqual( + await post(new BodyParser({ limit: 64 * 1024 }), body, headers), + refused, + ); + deepStrictEqual( + await post(new BodyParser({ limit: 1024 * 1024 + 64 }), body, headers), + accepted(hello), + ); + }); + + test( + "the reply of a refusal reaches a client that still sends the body", + { timeout: 10_000 }, + async () => { + deepStrictEqual( + await partial( + new BodyParser({ limit: 1024 }), + {}, + Buffer.alloc(4096, 0x61), + ), + { status: 413, connection: "close" }, + ); + deepStrictEqual( + await partial( + new BodyParser({ limit: 1024 }), + { "Content-Encoding": "gzip" }, + gzipSync(Buffer.alloc(64 * 1024)), + ), + { status: 413, connection: "close" }, + ); + }, + ); +}); diff --git a/packages/body/test/logging.test.ts b/packages/body/test/logging.test.ts index 0fc43929..eb7e0f97 100644 --- a/packages/body/test/logging.test.ts +++ b/packages/body/test/logging.test.ts @@ -1,74 +1,79 @@ import { deepStrictEqual, ok } from "node:assert/strict"; -import type { Server } from "node:http"; -import type { AddressInfo } from "node:net"; import { suite, test } from "node:test"; -import { Logger } from "@binden/logger"; -import { Binden, ct_json, serializers } from "binden"; -import { request } from "undici"; +import { Binden, ct_json, ct_text } from "binden"; import { BodyParser } from "../index.js"; +import { type IBody, Recorder, echo, listen, send } from "./helpers.js"; -// Invalid JSON: the error of `JSON.parse()` quotes the body -const body = '{"password": SECRET}'; - -/** Posts the body and returns the response status and the log lines */ +/** Sends a request through a parser, with a logger that keeps everything */ async function post( - logger: (destination: { write(line: string): void }) => Logger, -): Promise<[number, string]> { - const lines: string[] = []; - const destination = { - write: (line: string): void => { - lines.push(line); - }, - }; - const app = new Binden({ logger: logger(destination) }).use(new BodyParser()); - const server: Server = app.createServer(); - await new Promise((resolve) => { - server.listen(0, resolve); - }); - const url = `http://localhost:${(server.address() as AddressInfo).port}`; - + parser: BodyParser, + body: IBody, + headers: Record, +): Promise<[number, Recorder]> { + const logger = new Recorder(); + const server = await listen(new Binden({ logger }).use(parser, echo)); try { - const response = await request(url, { - method: "POST", - headers: { "Content-Type": ct_json }, - body, - }); - await response.body.dump(); - return [response.statusCode, lines.join("")]; + const { status } = await send(server.url, body, headers); + return [status, logger]; } finally { - await new Promise((resolve) => { - server.close(resolve); - }); + await server.close(); } } +// Invalid JSON: the error of `JSON.parse()` quotes the body +const secret = '{"password": SECRET}'; + suite("BodyParser (logging)", () => { - test("keeps the body out of the default logs", async () => { - const [status, output] = await post( - (destination) => new Logger({ serializers, destination, level: "debug" }), - ); + test("keeps the body out of the logs", async () => { + for (const parser of [ + new BodyParser(), + new BodyParser({ parse: (input): unknown => JSON.parse(input) }), + ]) { + const [status, logger] = await post(parser, secret, { + "Content-Type": ct_json, + }); + ok(status === 400 || status === 500, String(status)); + const output = JSON.stringify(logger.lines); + ok(logger.lines.length > 0); + ok(!output.includes("SECRET"), output); + } + }); - deepStrictEqual(status, 415); - ok(output.includes("Request body does not match provided Content-Type")); - ok(output.includes('"body":"[Redacted]"'), output); - ok(!output.includes("SECRET"), output); + test("a refusal is logged at `debug`", async () => { + const [status, logger] = await post(new BodyParser(), secret, { + "Content-Type": ct_json, + }); + deepStrictEqual(status, 400); + ok(logger.messages("debug").includes("The JSON body cannot be parsed")); + deepStrictEqual(logger.messages("error"), []); }); - test("logs the body with a custom serializer", async () => { - const [status, output] = await post( - (destination) => - new Logger({ - serializers: { - ...serializers, - body: (input: unknown): unknown => input, - }, - destination, - level: "debug", - }), + test("a failure of `type` is logged at `error`", async () => { + const parser = new BodyParser({ type: (): "raw" => "xml" as "raw" }); + const [status, logger] = await post(parser, "a", { + "Content-Type": ct_text, + }); + deepStrictEqual(status, 500); + ok( + logger + .messages("error") + .includes("The `type` function has returned an invalid kind"), ); + }); - deepStrictEqual(status, 415); - ok(output.includes(`"body":${JSON.stringify(body)}`), output); + test("a body that has already been read is logged at `error`", async () => { + const logger = new Recorder(); + const app = new Binden({ logger }).use(async (context) => { + await context.request.toArray(); + }, new BodyParser()); + const server = await listen(app); + try { + const reply = await send(server.url, "[]", { "Content-Type": ct_json }); + deepStrictEqual(reply.status, 500); + } finally { + await server.close(); + } + ok(logger.messages("error").includes("The body has already been read")); }); }); diff --git a/packages/body/test/options.test.ts b/packages/body/test/options.test.ts new file mode 100644 index 00000000..e04c0de0 --- /dev/null +++ b/packages/body/test/options.test.ts @@ -0,0 +1,120 @@ +import { deepStrictEqual, throws } from "node:assert/strict"; +import { suite, test } from "node:test"; + +import { BodyParser, type IBodyParserOptions } from "../index.js"; + +/** Options of the wrong type, for the tests */ +function options(input: Record): IBodyParserOptions { + return input; +} + +suite("BodyParser (options)", () => { + test("`parse` is a function", () => { + throws( + () => new BodyParser(options({ parse: 2 })), + new TypeError("`parse` is not a function"), + ); + }); + + test("the limits are non-negative integers or `Infinity`", () => { + for (const name of ["limit", "encoded_limit", "parameter_limit"]) { + for (const value of ["1", null, Number.NaN, 1n]) { + throws( + () => new BodyParser(options({ [name]: value })), + new TypeError(`\`${name}\` is not a number`), + ); + } + for (const value of [-1, 1.5, -Infinity, 2 ** 53]) { + throws( + () => new BodyParser(options({ [name]: value })), + new RangeError( + `\`${name}\` is not a non-negative integer or \`Infinity\``, + ), + ); + } + for (const value of [0, 1, Number.MAX_SAFE_INTEGER, Infinity]) { + deepStrictEqual( + new BodyParser(options({ [name]: value })) instanceof BodyParser, + true, + ); + } + } + }); + + test("the flags are booleans", () => { + for (const name of ["raw", "strict"]) { + for (const value of [1, "true", null]) { + throws( + () => new BodyParser(options({ [name]: value })), + new TypeError(`\`${name}\` is not a boolean`), + ); + } + } + // The options of every middleware + throws( + () => new BodyParser(options({ disabled: 1 })), + new TypeError("`disabled` is not a boolean"), + ); + deepStrictEqual( + new BodyParser({ ignore_errors: true }).ignore_errors, + true, + ); + }); + + test("`type` is a list of media types or a function", () => { + throws( + () => new BodyParser(options({ type: "application/json" })), + new TypeError("`type` is not an array or a function"), + ); + for (const entry of [ + "json", + "", + "+", + "application/json; charset=utf-8", + "application/", + "/json", + " text/plain", + 1, + ]) { + throws( + () => new BodyParser(options({ type: [entry] })), + new TypeError(`Invalid media type in \`type\`: ${String(entry)}`), + ); + } + const types = ["text/*", "*/*", "+json", "Application/JSON", "a/b"]; + deepStrictEqual( + new BodyParser({ type: types }) instanceof BodyParser, + true, + ); + deepStrictEqual(new BodyParser({ type: [] }) instanceof BodyParser, true); + deepStrictEqual( + new BodyParser({ type: (): null => null }) instanceof BodyParser, + true, + ); + }); + + test("`raw` cannot be used with a `type` function", () => { + throws( + () => new BodyParser({ raw: true, type: (): "raw" => "raw" }), + new TypeError("`raw` cannot be used with a `type` function"), + ); + // Without `raw`, or with a list + deepStrictEqual( + new BodyParser({ raw: false, type: (): "raw" => "raw" }) instanceof + BodyParser, + true, + ); + deepStrictEqual( + new BodyParser({ raw: true, type: ["text/plain"] }) instanceof BodyParser, + true, + ); + }); + + test("the unsupported methods", () => { + const methods = BodyParser.unsupported_methods; + deepStrictEqual(methods, new Set(["GET", "HEAD", "OPTIONS", "TRACE"])); + // A copy + methods.delete("GET"); + deepStrictEqual(BodyParser.unsupported_methods.has("GET"), true); + }); +}); diff --git a/packages/body/test/process.test.ts b/packages/body/test/process.test.ts new file mode 100644 index 00000000..feac5e7d --- /dev/null +++ b/packages/body/test/process.test.ts @@ -0,0 +1,28 @@ +import { deepStrictEqual } from "node:assert"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { suite, test } from "node:test"; + +/** + * Runs a fixture (`test/fixtures`) in a process of its own: a crash (e.g. an + * unhandled rejection) is its exit code and output, not a failure of the test + * runner + */ +function run(fixture: string): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + ["--import", "tsx", join(import.meta.dirname, "fixtures", fixture)], + { encoding: "utf8", timeout: 30_000 }, + ); + return { status, stdout, stderr }; +} + +suite("BodyParser (process safety)", () => { + test("late rejections of `parse` and `type` do not crash the process", () => { + deepStrictEqual(run("late-rejections.ts"), { + status: 0, + stdout: "503 500\n", + stderr: "", + }); + }); +}); diff --git a/packages/body/test/types.test.ts b/packages/body/test/types.test.ts new file mode 100644 index 00000000..2d7cceaf --- /dev/null +++ b/packages/body/test/types.test.ts @@ -0,0 +1,83 @@ +// Type tests: compiled by `tsc` (the build of the package), where every +// `@ts-expect-error` must be an error. The test only checks that the typed +// values can be created. +import { ok } from "node:assert/strict"; +import { test } from "node:test"; +import { Ajv as AJV, type JTDDataType } from "ajv/dist/jtd.js"; + +import { + BodyParser, + type IBodyKind, + type IBodyParserOptions, + type IParse, +} from "../index.js"; + +interface IUser { + user: string; +} + +const none = ({} as { value?: undefined }).value; + +/** Checks that a value has the type `T` */ +function is(value: T): T { + return value; +} + +test("types", () => { + // The type of the result is inferred from `parse` + is>( + new BodyParser({ parse: (input): IUser => ({ user: input }) }), + ); + is>( + new BodyParser({ + parse: (input): IUser | undefined => { + if (input === "") { + return none; + } + return { user: input }; + }, + }), + ); + is>( + new BodyParser({ + parse: (input): Promise => Promise.resolve({ user: input }), + }), + ); + const schema = { properties: { user: { type: "string" } } } as const; + const parse = new AJV().compileParser>(schema); + is>(new BodyParser({ parse })); + is>(parse); + is(new BodyParser()); + // @ts-expect-error -- a parser of another type + is>(new BodyParser({ parse: (): number => 1 })); + // @ts-expect-error -- `parse` gets a string + is(new BodyParser({ parse: (input: number): number => input })); + + // `type` returns a kind (or `null`) + is( + new BodyParser({ + type: (content_type): IBodyKind | null => { + const { type } = content_type; + return type === "application/csp-report" ? "json" : null; + }, + }), + ); + // @ts-expect-error -- not a kind + is(new BodyParser({ type: (): string => "xml" })); + // @ts-expect-error -- a list of strings + is(() => new BodyParser({ type: [1] })); + + // Every option may be `undefined` + const options: Required> = { + parse: none, + limit: none, + encoded_limit: none, + type: none, + raw: none, + strict: none, + parameter_limit: none, + disabled: none, + ignore_errors: none, + }; + ok(is>(new BodyParser(options)) instanceof BodyParser); +}); From 492dc005eb5680eecc85fefc80d4ebcfd2cfd1a5 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 01:53:58 +0300 Subject: [PATCH 154/675] feat: verify the request bodies before they are parsed in body The signature of a webhook covers the bytes of its body, which the parsed value no longer has. `verify(decoded, context, { encoded })` gets the body once its content codings are removed, before its charset is decoded and before `parse`, even when it is empty; with `verify_encoded` it gets the body as received too (within `encoded_limit`, and without a copy when there are no codings). It may be asynchronous: like `parse`, it is awaited against context.signal. A BindenError that it throws is the reply, any other failure is a 500, and its result is ignored. Co-Authored-By: Claude Opus 5.5 --- packages/body/README.md | 52 ++++- packages/body/index.ts | 75 ++++++- packages/body/test/abort.test.ts | 27 +++ .../body/test/fixtures/late-rejections.ts | 20 +- packages/body/test/logging.test.ts | 3 + packages/body/test/options.test.ts | 8 +- packages/body/test/process.test.ts | 4 +- packages/body/test/types.test.ts | 15 ++ packages/body/test/verify.test.ts | 190 ++++++++++++++++++ 9 files changed, 371 insertions(+), 23 deletions(-) create mode 100644 packages/body/test/verify.test.ts diff --git a/packages/body/README.md b/packages/body/README.md index e8590cdc..d842cc15 100644 --- a/packages/body/README.md +++ b/packages/body/README.md @@ -50,6 +50,8 @@ The default export is a `BodyParser` with the default options (no limits). | `raw` | `false` | Whether `application/octet-stream` is parsed, as a `Buffer` | | `parse` | `JSON.parse` | Parses a JSON body, synchronously or not (see [Custom `parse`](#custom-parse)) | | `strict` | `true` | Whether the default `parse` accepts only an object or an array | +| `verify` | none | Checks the decoded body before it is parsed (see [Verification](#verification)) | +| `verify_encoded` | `false` | Whether `verify` gets the body as received too | | `disabled` | `false` | Whether the middleware is skipped | | `ignore_errors` | `false` | Whether its errors are ignored | @@ -168,10 +170,52 @@ new Binden() }); ``` +## Verification + +`verify(decoded, context, { encoded })` checks a body before it is parsed, synchronously or not: +it gets the body once its content codings are removed (a `Buffer`, before its charset is decoded), +even when it is empty, and, with `verify_encoded: true`, the body as received (`encoded`, within +`encoded_limit`; `null` otherwise). A `BindenError` that it throws is the reply, any other failure +is a `500 Internal Server Error`, and its result is ignored. For example, the signature of a +webhook: + +```typescript +import { createHmac, timingSafeEqual } from "node:crypto"; +import { BindenError, type Context, Router } from "binden"; +import { BodyParser } from "@binden/body"; + +const secret = process.env.WEBHOOK_SECRET ?? ""; + +const webhook = new BodyParser({ + limit: 1024 * 1024, + encoded_limit: 1024 * 1024, + verify: (decoded: Buffer, context: Context): void => { + const header = context.request.headers["x-signature"]; + const signature = Buffer.from( + typeof header === "string" ? header : "", + "hex", + ); + const expected = createHmac("sha256", secret).update(decoded).digest(); + if ( + signature.length !== expected.length || + !timingSafeEqual(signature, expected) + ) { + throw new BindenError(401); + } + }, +}); + +app.use( + "/webhook", + new Router().post(webhook, (context: Context) => context.send()), +); +``` + ## Aborted requests -The reading and `parse` stop once `context.signal` is aborted: a promise of `parse` that never -settles is abandoned (its late rejection is observed), and the parser does not stay pending. +The reading, `verify` and `parse` stop once `context.signal` is aborted: a promise of `verify` or +`parse` that never settles is abandoned (its late rejection is observed), and the parser does not +stay pending. - A client that goes away gets a `BindenError(400, { code: "aborted" })` (logged at `debug`). - After the deadline of the request (or the timeout of a shutdown), the parser rejects with the @@ -188,8 +232,8 @@ settles is abandoned (its late rejection is observed), and the parser does not s | A limit is exceeded (`limit`, `encoded_limit`, `parameter_limit`) | `413 Content Too Large` | | An unknown or unsupported content coding, more than two codings, an unknown charset | `415 Unsupported Media Type` | | A content type that is not selected | skipped (the body is not read) | -| A `BindenError` of `parse` | that error | -| Any other failure of `parse`, a `type` function that fails, a body that something else has read | `500 Internal Server Error` | +| A `BindenError` of `parse` or `verify` | that error | +| Any other failure of `parse` or `verify`, a `type` function that fails, a body that something else has read | `500 Internal Server Error` | The parser never logs the body itself: its logs have the reason of the refusal, and the type, known `code` and status of an error. diff --git a/packages/body/index.ts b/packages/body/index.ts index 541790f8..c96f7444 100644 --- a/packages/body/index.ts +++ b/packages/body/index.ts @@ -36,6 +36,23 @@ export type IParse = ( input: string, ) => T | undefined | Promise; +/** What `verify` gets besides the decoded body */ +export interface IVerifyInfo { + /** The body as received (before its content codings are removed), with `verify_encoded` only */ + readonly encoded: Buffer | null; +} + +/** + * Checks a body (e.g. its signature) before it is parsed, synchronously or + * not: a `BindenError` that it throws is the reply, any other failure is a + * `500 Internal Server Error` + */ +export type IVerify = ( + decoded: Buffer, + context: Context, + info: IVerifyInfo, +) => unknown; + /** The options of a `BodyParser` */ export interface IBodyParserOptions extends IMiddlewareParams { /** @@ -66,6 +83,10 @@ export interface IBodyParserOptions extends IMiddlewareParams { strict?: boolean | undefined; /** The most parameters of a form (`1000`): more get a `413 Content Too Large` */ parameter_limit?: number | undefined; + /** Checks the decoded body before it is parsed, even when it is empty */ + verify?: IVerify | undefined; + /** Whether `verify` gets the body as received too (`false`) */ + verify_encoded?: boolean | undefined; } /** A decoder of a content coding, created once every coding is known to be supported */ @@ -202,10 +223,10 @@ function abortError(reason: unknown): Error { } /** - * Counts the bytes that pass through it: more than `limit` fails it with a - * `413 Content Too Large` + * Counts the bytes that pass through it (and keeps them in `kept`, if any): + * more than `limit` fails it with a `413 Content Too Large` */ -function counter(limit: number): Transform { +function counter(limit: number, kept: Buffer[] | null): Transform { let size = 0; return new Transform({ transform(chunk: Buffer, _encoding, callback): void { @@ -214,6 +235,7 @@ function counter(limit: number): Transform { callback(new BindenError(413)); return; } + kept?.push(chunk); callback(null, chunk); }, }); @@ -233,6 +255,8 @@ export class BodyParser extends Middleware { readonly #type: ((content_type: ContentType) => unknown) | null; readonly #strict: boolean; readonly #parameter_limit: number; + readonly #verify: IVerify | null; + readonly #verify_encoded: boolean; public constructor({ parse, @@ -242,17 +266,22 @@ export class BodyParser extends Middleware { raw = false, strict = true, parameter_limit = 1000, + verify, + verify_encoded = false, ...rest }: IBodyParserOptions = {}) { super(rest); if (typeof parse !== "undefined" && typeof parse !== "function") { throw new TypeError("`parse` is not a function"); + } else if (typeof verify !== "undefined" && typeof verify !== "function") { + throw new TypeError("`verify` is not a function"); } this.#limit = validateLimit("limit", limit); this.#encoded_limit = validateLimit("encoded_limit", encoded_limit); this.#parameter_limit = validateLimit("parameter_limit", parameter_limit); validateBoolean("raw", raw); this.#strict = validateBoolean("strict", strict); + this.#verify_encoded = validateBoolean("verify_encoded", verify_encoded); if (typeof type === "function") { if (raw) { @@ -274,6 +303,7 @@ export class BodyParser extends Middleware { typeof parse === "undefined" || (parse as unknown) === JSON.parse ? null : parse; + this.#verify = verify ?? null; } public async run(context: Context): Promise { @@ -299,7 +329,27 @@ export class BodyParser extends Middleware { claimed.add(request); const { decoders, decoder } = this.#prepare(context, kind, log); - const decoded = await this.#read(context, decoders, log); + const { decoded, encoded } = await this.#read(context, decoders, log); + + if (this.#verify) { + const verify = this.#verify; + const info: IVerifyInfo = { + encoded: this.#verify_encoded ? (encoded ?? decoded) : null, + }; + const outcome = await BodyParser.#call( + () => verify(decoded, context, info), + context, + log, + ); + if (!outcome.ok) { + log.debug("`verify` has refused the body", { + failure: serializers.error(outcome.error), + }); + throw outcome.error instanceof BindenError + ? outcome.error + : new BindenError(500, { cause: outcome.error }); + } + } let text = ""; if (decoder) { @@ -448,15 +498,17 @@ export class BodyParser extends Middleware { context: Context, decoders: readonly IDecoder[], log: ILog, - ): Promise { + ): Promise<{ decoded: Buffer; encoded: Buffer | null }> { const { request, response, signal } = context; - const wire = counter(this.#encoded_limit); + const kept: Buffer[] | null = + this.#verify_encoded && decoders.length > 0 ? [] : null; + const wire = counter(this.#encoded_limit, kept); const stages: Duplex[] = [wire]; for (const create of decoders) { - stages.push(create(), counter(this.#limit)); + stages.push(create(), counter(this.#limit, null)); } if (decoders.length === 0) { - stages.push(counter(this.#limit)); + stages.push(counter(this.#limit, null)); } const chunks: Buffer[] = []; const sink = new Writable({ @@ -515,7 +567,10 @@ export class BodyParser extends Middleware { const invalid = new BindenError(400, { cause: error }); fail(invalid, "The body cannot be decoded"); } else if (settle()) { - resolve(Buffer.concat(chunks)); + resolve({ + decoded: Buffer.concat(chunks), + encoded: kept === null ? null : Buffer.concat(kept), + }); } }); request.pipe(wire); @@ -577,7 +632,7 @@ export class BodyParser extends Middleware { } /** - * Calls a callback of the options (`parse`) and awaits its + * Calls a callback of the options (`parse` or `verify`) and awaits its * result, unless the request is aborted first: its late result is then * ignored, and its late rejection observed */ diff --git a/packages/body/test/abort.test.ts b/packages/body/test/abort.test.ts index 290b3e25..28f60ca9 100644 --- a/packages/body/test/abort.test.ts +++ b/packages/body/test/abort.test.ts @@ -150,6 +150,33 @@ suite("BodyParser (aborted requests)", () => { } }); + test( + "a `verify` that never settles is abandoned at the deadline", + { timeout: 10_000 }, + async () => { + const watched = watch(new BodyParser({ verify: never })); + const server = await listen( + new Binden({ timeout: 100 }).use(watched.middleware), + ); + try { + const { req, response } = upload( + server, + { "Content-Type": ct_json, "Content-Length": 2 }, + "[]", + ); + req.end(); + const reply = await within(response); + reply.resume(); + deepStrictEqual(reply.statusCode, 503); + const error = failure(await within(watched.settled)); + ok(error instanceof BindenAbortError); + deepStrictEqual(error.reason, "deadline"); + } finally { + await server.close(); + } + }, + ); + test( "a `parse` that never settles is abandoned once the client has gone away", { timeout: 10_000 }, diff --git a/packages/body/test/fixtures/late-rejections.ts b/packages/body/test/fixtures/late-rejections.ts index 3f2b3596..103d4c54 100644 --- a/packages/body/test/fixtures/late-rejections.ts +++ b/packages/body/test/fixtures/late-rejections.ts @@ -1,10 +1,10 @@ -// Run by `process.test.ts`: the late rejections of `parse` and of a `type` -// function must not crash the process (`unhandledRejection`), and the parser -// must not keep a request without a reply +// Run by `process.test.ts`: the late rejections of `verify`, `parse` and a +// `type` function must not crash the process (`unhandledRejection`), and the +// parser must not keep a request without a reply import { type IncomingMessage, request } from "node:http"; import type { AddressInfo } from "node:net"; import { once } from "node:events"; -import { Binden } from "binden"; +import { Binden, BindenError } from "binden"; import { BodyParser } from "../../index.js"; @@ -29,6 +29,16 @@ const late = async (): Promise => { }; const app = new Binden({ timeout: 30 }) + .use("/verify", new BodyParser({ verify: late })) + .use( + "/verify-binden", + new BodyParser({ + verify: async (): Promise => { + await sleep(80); + throw new BindenError(401); + }, + }), + ) .use("/parse", new BodyParser({ parse: late })) .use( "/type", @@ -43,7 +53,7 @@ await once(server, "listening"); const { port } = server.address() as AddressInfo; const statuses: number[] = []; -for (const path of ["/parse", "/type"]) { +for (const path of ["/verify", "/verify-binden", "/parse", "/type"]) { const response = await new Promise((resolve, reject) => { request( { diff --git a/packages/body/test/logging.test.ts b/packages/body/test/logging.test.ts index eb7e0f97..6c072b7c 100644 --- a/packages/body/test/logging.test.ts +++ b/packages/body/test/logging.test.ts @@ -29,6 +29,9 @@ suite("BodyParser (logging)", () => { for (const parser of [ new BodyParser(), new BodyParser({ parse: (input): unknown => JSON.parse(input) }), + new BodyParser({ + verify: (decoded): unknown => JSON.parse(decoded.toString()), + }), ]) { const [status, logger] = await post(parser, secret, { "Content-Type": ct_json, diff --git a/packages/body/test/options.test.ts b/packages/body/test/options.test.ts index e04c0de0..f93883f7 100644 --- a/packages/body/test/options.test.ts +++ b/packages/body/test/options.test.ts @@ -9,11 +9,15 @@ function options(input: Record): IBodyParserOptions { } suite("BodyParser (options)", () => { - test("`parse` is a function", () => { + test("`parse` and `verify` are functions", () => { throws( () => new BodyParser(options({ parse: 2 })), new TypeError("`parse` is not a function"), ); + throws( + () => new BodyParser(options({ verify: "verify" })), + new TypeError("`verify` is not a function"), + ); }); test("the limits are non-negative integers or `Infinity`", () => { @@ -42,7 +46,7 @@ suite("BodyParser (options)", () => { }); test("the flags are booleans", () => { - for (const name of ["raw", "strict"]) { + for (const name of ["raw", "strict", "verify_encoded"]) { for (const value of [1, "true", null]) { throws( () => new BodyParser(options({ [name]: value })), diff --git a/packages/body/test/process.test.ts b/packages/body/test/process.test.ts index feac5e7d..6d49e761 100644 --- a/packages/body/test/process.test.ts +++ b/packages/body/test/process.test.ts @@ -18,10 +18,10 @@ function run(fixture: string): Record { } suite("BodyParser (process safety)", () => { - test("late rejections of `parse` and `type` do not crash the process", () => { + test("late rejections of `verify`, `parse` and `type` do not crash the process", () => { deepStrictEqual(run("late-rejections.ts"), { status: 0, - stdout: "503 500\n", + stdout: "503 503 503 500\n", stderr: "", }); }); diff --git a/packages/body/test/types.test.ts b/packages/body/test/types.test.ts index 2d7cceaf..40b97421 100644 --- a/packages/body/test/types.test.ts +++ b/packages/body/test/types.test.ts @@ -4,6 +4,7 @@ import { ok } from "node:assert/strict"; import { test } from "node:test"; import { Ajv as AJV, type JTDDataType } from "ajv/dist/jtd.js"; +import type { Context } from "binden"; import { BodyParser, @@ -53,6 +54,18 @@ test("types", () => { // @ts-expect-error -- `parse` gets a string is(new BodyParser({ parse: (input: number): number => input })); + // `verify` gets the decoded body, the context and the encoded body (if any) + is( + new BodyParser({ + verify: (decoded, context, { encoded }): void => { + is(decoded); + is(context); + is(encoded); + }, + }), + ); + is(new BodyParser({ verify: (): Promise => Promise.resolve(true) })); + // `type` returns a kind (or `null`) is( new BodyParser({ @@ -76,6 +89,8 @@ test("types", () => { raw: none, strict: none, parameter_limit: none, + verify: none, + verify_encoded: none, disabled: none, ignore_errors: none, }; diff --git a/packages/body/test/verify.test.ts b/packages/body/test/verify.test.ts new file mode 100644 index 00000000..05b9f1bd --- /dev/null +++ b/packages/body/test/verify.test.ts @@ -0,0 +1,190 @@ +import { deepStrictEqual, ok } from "node:assert/strict"; +import { createHmac, timingSafeEqual } from "node:crypto"; +import { suite, test } from "node:test"; +import { gzipSync } from "node:zlib"; +import { BindenError, type Context, ct_json, ct_text } from "binden"; + +import { BodyParser, type IVerify, type IVerifyInfo } from "../index.js"; +import { type IBody, type IReply, send, serve } from "./helpers.js"; + +/** Sends one request through the parser (then `echo`) */ +async function post( + parser: BodyParser, + body: IBody, + headers: Record = {}, +): Promise { + const server = await serve(parser); + try { + return await send(server.url, body, { + "Content-Type": ct_json, + ...headers, + }); + } finally { + await server.close(); + } +} + +interface ICall { + decoded: string; + context: boolean; + encoded: string | null; +} + +/** A `verify` that records its calls */ +function recorder(calls: ICall[]): IVerify { + return (decoded: Buffer, context: Context, info: IVerifyInfo): void => { + calls.push({ + decoded: decoded.toString("hex"), + context: typeof context.request.method === "string", + encoded: info.encoded === null ? null : info.encoded.toString("hex"), + }); + }; +} + +const hex = (input: Buffer | string): string => + Buffer.from(input).toString("hex"); + +suite("BodyParser (verify)", () => { + test("`verify` gets the decoded body and the context", async () => { + const calls: ICall[] = []; + const parser = new BodyParser({ verify: recorder(calls) }); + const body = gzipSync("[1]"); + deepStrictEqual( + (await post(parser, body, { "Content-Encoding": "gzip" })).body, + { type: "json", value: [1] }, + ); + deepStrictEqual(calls, [ + { decoded: hex("[1]"), context: true, encoded: null }, + ]); + }); + + test("`verify_encoded`: the body as received too", async () => { + const calls: ICall[] = []; + const parser = new BodyParser({ + verify: recorder(calls), + verify_encoded: true, + }); + const body = gzipSync("[1]"); + deepStrictEqual( + (await post(parser, body, { "Content-Encoding": "gzip" })).status, + 200, + ); + // Without codings, both are the same + deepStrictEqual((await post(parser, "[2]")).status, 200); + deepStrictEqual(calls, [ + { decoded: hex("[1]"), context: true, encoded: hex(body) }, + { decoded: hex("[2]"), context: true, encoded: hex("[2]") }, + ]); + }); + + test("`verify` checks an empty body too", async () => { + const calls: ICall[] = []; + const parser = new BodyParser({ + verify: recorder(calls), + verify_encoded: true, + }); + deepStrictEqual((await post(parser, "")).body, { type: "undefined" }); + deepStrictEqual( + (await post(parser, "", { "Content-Type": ct_text })).body, + { + type: "text", + value: "", + }, + ); + deepStrictEqual(calls, [ + { decoded: "", context: true, encoded: "" }, + { decoded: "", context: true, encoded: "" }, + ]); + }); + + test("the failures of `verify`", async () => { + const unauthorized = (): never => { + throw new BindenError(401); + }; + const failure = (): never => { + throw new Error("A failure"); + }; + const statuses: [IVerify, number][] = [ + [unauthorized, 401], + [(): Promise => Promise.reject(new BindenError(403)), 403], + [failure, 500], + [(): Promise => Promise.reject(new TypeError("A failure")), 500], + // Its result does not matter + [(): boolean => false, 200], + [(): Promise => Promise.resolve(false), 200], + ]; + for (const [verify, status] of statuses) { + const parser = new BodyParser({ verify }); + deepStrictEqual((await post(parser, "[]")).status, status); + } + }); + + test("`verify` comes before the charset and `parse`", async () => { + const order: string[] = []; + const parser = new BodyParser({ + verify: (): never => { + order.push("verify"); + throw new BindenError(403); + }, + parse: (input: string): unknown => { + order.push("parse"); + return input; + }, + }); + // Not valid UTF-8 + deepStrictEqual( + ( + await post(parser, Buffer.from([0x7b, 0xff, 0x7d]), { + "Content-Type": ct_text, + }) + ).status, + 403, + ); + deepStrictEqual((await post(parser, "[]")).status, 403); + deepStrictEqual(order, ["verify", "verify"]); + }); + + test("`verify` does not check a refused body or one that is not selected", async () => { + const calls: ICall[] = []; + const parser = new BodyParser({ + verify: recorder(calls), + encoded_limit: 2, + }); + deepStrictEqual((await post(parser, "[1]")).status, 413); + deepStrictEqual( + (await post(parser, "[1]", { "Content-Type": "image/png" })).body, + { type: "undefined" }, + ); + deepStrictEqual(calls, []); + }); + + test("a webhook signature", async () => { + const secret = "secret"; + const parser = new BodyParser({ + verify: (decoded, context): void => { + const signature = context.request.headers["x-signature"]; + const expected = createHmac("sha256", secret).update(decoded).digest(); + const given = Buffer.from( + typeof signature === "string" ? signature : "", + "hex", + ); + if ( + given.length !== expected.length || + !timingSafeEqual(given, expected) + ) { + throw new BindenError(401); + } + }, + }); + const body = '{"event":"paid"}'; + const signature = createHmac("sha256", secret).update(body).digest("hex"); + const signed = await post(parser, gzipSync(body), { + "Content-Encoding": "gzip", + "X-Signature": signature, + }); + deepStrictEqual(signed.body, { type: "json", value: { event: "paid" } }); + const forged = await post(parser, body, { "X-Signature": "00" }); + deepStrictEqual(forged.status, 401); + ok(signature.length > 0); + }); +}); From 6e3072698dc3527f90b7e331c0ffb399ea7a4d39 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 01:55:12 +0300 Subject: [PATCH 155/675] feat: decode the zstd content coding on opt-in in body Clients start to send zstd bodies, but the zstd API of Node.js is experimental, and a zstd decoder allocates the window that a frame declares, whatever the limit of the body is. `zstd: true` decodes the zstd coding (415 without it, as before). The window is limited to 8 MiB, as RFC 9659 allows, so that a frame that declares a larger one is a 400. Co-Authored-By: Claude Opus 5.5 --- packages/body/README.md | 6 ++++++ packages/body/index.ts | 25 ++++++++++++++++++++++--- packages/body/test/codings.test.ts | 29 +++++++++++++++++++++++++++++ packages/body/test/options.test.ts | 2 +- packages/body/test/types.test.ts | 1 + 5 files changed, 59 insertions(+), 4 deletions(-) diff --git a/packages/body/README.md b/packages/body/README.md index d842cc15..d960e1c4 100644 --- a/packages/body/README.md +++ b/packages/body/README.md @@ -52,6 +52,7 @@ The default export is a `BodyParser` with the default options (no limits). | `strict` | `true` | Whether the default `parse` accepts only an object or an array | | `verify` | none | Checks the decoded body before it is parsed (see [Verification](#verification)) | | `verify_encoded` | `false` | Whether `verify` gets the body as received too | +| `zstd` | `false` | Whether the `zstd` content coding is decoded (see [Content codings](#content-codings)) | | `disabled` | `false` | Whether the middleware is skipped | | `ignore_errors` | `false` | Whether its errors are ignored | @@ -130,6 +131,11 @@ A body is parsed once: another `BodyParser` that selects it later skips it. before the body is read; corrupt data gets `400 Bad Request`. The codings of a body that is not selected are not checked. +`zstd: true` decodes `zstd` too. Its API is experimental in Node.js, and a `zstd` decoder +allocates the window that a frame declares, whatever `limit` is: the parser refuses a window larger +than 8 MiB (`400 Bad Request`, as RFC 9659 allows), so a `zstd` body may still take up to 8 MiB of +memory per decoder. + ## Custom `parse` `parse` replaces `JSON.parse()` for the JSON bodies, synchronously or not: its result (or what its diff --git a/packages/body/index.ts b/packages/body/index.ts index c96f7444..909d3f10 100644 --- a/packages/body/index.ts +++ b/packages/body/index.ts @@ -6,7 +6,13 @@ import { finished, pipeline, } from "node:stream"; -import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib"; +import { + constants, + createBrotliDecompress, + createGunzip, + createInflate, + createZstdDecompress, +} from "node:zlib"; import { BindenAbortError, BindenError, @@ -87,6 +93,8 @@ export interface IBodyParserOptions extends IMiddlewareParams { verify?: IVerify | undefined; /** Whether `verify` gets the body as received too (`false`) */ verify_encoded?: boolean | undefined; + /** Whether the `zstd` content coding is decoded (`false`): the API of Node.js is experimental */ + zstd?: boolean | undefined; } /** A decoder of a content coding, created once every coding is known to be supported */ @@ -100,6 +108,9 @@ type IOutcome = /** The logger of the parser (`context.log`, whose calls never fail) */ type ILog = Context["log"]; +/** The largest window of a `zstd` frame (8 MiB, RFC 9659) */ +const ZSTD_WINDOW_LOG_MAX = 23; + /** The most content codings that a body may have */ const MAX_CODINGS = 2; @@ -257,6 +268,7 @@ export class BodyParser extends Middleware { readonly #parameter_limit: number; readonly #verify: IVerify | null; readonly #verify_encoded: boolean; + readonly #zstd: boolean; public constructor({ parse, @@ -268,6 +280,7 @@ export class BodyParser extends Middleware { parameter_limit = 1000, verify, verify_encoded = false, + zstd = false, ...rest }: IBodyParserOptions = {}) { super(rest); @@ -282,6 +295,7 @@ export class BodyParser extends Middleware { validateBoolean("raw", raw); this.#strict = validateBoolean("strict", strict); this.#verify_encoded = validateBoolean("verify_encoded", verify_encoded); + this.#zstd = validateBoolean("zstd", zstd); if (typeof type === "function") { if (raw) { @@ -444,7 +458,7 @@ export class BodyParser extends Middleware { const decoders: IDecoder[] = []; for (const { encoding } of codings) { - const decoder = BodyParser.#decoder(encoding); + const decoder = this.#decoder(encoding); if (decoder === null) { throw refuse(new BindenError(415), "Unsupported content coding", { encoding, @@ -476,13 +490,18 @@ export class BodyParser extends Middleware { } /** The decoder of a content coding, `null` when it is not supported */ - static #decoder(encoding: string): IDecoder | null { + #decoder(encoding: string): IDecoder | null { if (encoding === "gzip" || encoding === "x-gzip") { return createGunzip; } else if (encoding === "deflate") { return createInflate; } else if (encoding === "br") { return createBrotliDecompress; + } else if (encoding === "zstd" && this.#zstd) { + return (): Duplex => + createZstdDecompress({ + params: { [constants.ZSTD_d_windowLogMax]: ZSTD_WINDOW_LOG_MAX }, + }); } return null; } diff --git a/packages/body/test/codings.test.ts b/packages/body/test/codings.test.ts index 0dcaf377..7630f626 100644 --- a/packages/body/test/codings.test.ts +++ b/packages/body/test/codings.test.ts @@ -2,6 +2,8 @@ import { deepStrictEqual, ok } from "node:assert/strict"; import { suite, test } from "node:test"; import { brotliCompressSync, + constants, + createZstdCompress, deflateSync, gzipSync, zstdCompressSync, @@ -33,6 +35,15 @@ function text(coding: string): Record { const hello = "Hello World"; const parsed = { type: "text", value: hello }; +/** A `zstd` frame that declares a window of 128 MiB */ +async function largeWindow(): Promise { + const encoder = createZstdCompress({ + params: { [constants.ZSTD_c_windowLog]: 27 }, + }); + encoder.end(hello); + return Buffer.concat(await encoder.toArray()); +} + suite("BodyParser (content codings)", () => { test("gzip, x-gzip, deflate and br", async () => { const parser = new BodyParser(); @@ -87,6 +98,24 @@ suite("BodyParser (content codings)", () => { } }); + test("`zstd`", async () => { + const parser = new BodyParser({ zstd: true }); + const body = zstdCompressSync(hello); + deepStrictEqual((await post(parser, body, text("zstd"))).body, parsed); + const both = gzipSync(zstdCompressSync(hello)); + deepStrictEqual( + (await post(parser, both, text("zstd, gzip"))).body, + parsed, + ); + // A window larger than 8 MiB (RFC 9659) + const large = await largeWindow(); + deepStrictEqual(await post(parser, large, text("zstd")), { + status: 400, + connection: "close", + body: null, + }); + }); + test("corrupt data is a 400", async () => { const parser = new BodyParser(); const truncated = gzipSync(hello).subarray(0, 12); diff --git a/packages/body/test/options.test.ts b/packages/body/test/options.test.ts index f93883f7..b8223572 100644 --- a/packages/body/test/options.test.ts +++ b/packages/body/test/options.test.ts @@ -46,7 +46,7 @@ suite("BodyParser (options)", () => { }); test("the flags are booleans", () => { - for (const name of ["raw", "strict", "verify_encoded"]) { + for (const name of ["raw", "strict", "verify_encoded", "zstd"]) { for (const value of [1, "true", null]) { throws( () => new BodyParser(options({ [name]: value })), diff --git a/packages/body/test/types.test.ts b/packages/body/test/types.test.ts index 40b97421..00db873c 100644 --- a/packages/body/test/types.test.ts +++ b/packages/body/test/types.test.ts @@ -91,6 +91,7 @@ test("types", () => { parameter_limit: none, verify: none, verify_encoded: none, + zstd: none, disabled: none, ignore_errors: none, }; From 5ab4625bd780acdb8bb99b6edccfb6eba1383292 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 01:18:26 +0300 Subject: [PATCH 156/675] build: move @types/busboy to the dependencies in multipart The declarations of the package refer to the types of busboy (its options are those of `config`), so an application that type checks its use of the middleware needs them. As a peer dependency they were left to the application to install, and a missing one broke its build with errors about a module it never imported. They are now installed with the package. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 3 +-- packages/multipart/package.json | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 55ac653c..4e4a2853 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1985,7 +1985,6 @@ "resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.5.tgz", "integrity": "sha512-IwqrfVFxWa23c4WeE7uZswssCqGfvnl5Q7b1pdshbT3HaQDa3UIbJx5BAxtcj9nZYX9tVDArM9TUv9SpvORbpQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -9215,6 +9214,7 @@ "version": "0.0.0-development", "license": "MIT", "dependencies": { + "@types/busboy": "^1.5.5", "busboy": "^1.6.0" }, "devDependencies": { @@ -9229,7 +9229,6 @@ "url": "https://commerce.coinbase.com/checkout/eddfb0f7-20e7-4e0d-8e04-232bbdf0b397" }, "peerDependencies": { - "@types/busboy": "^1.5.5", "binden": "^0.0.0-development" } }, diff --git a/packages/multipart/package.json b/packages/multipart/package.json index 5e51521a..5de2c719 100644 --- a/packages/multipart/package.json +++ b/packages/multipart/package.json @@ -37,10 +37,10 @@ }, "homepage": "https://github.com/binden-js/binden/tree/main/packages/multipart#readme", "dependencies": { + "@types/busboy": "^1.5.5", "busboy": "^1.6.0" }, "peerDependencies": { - "@types/busboy": "^1.5.5", "binden": "^0.0.0-development" }, "devDependencies": { From 9e6336d66a92a45e12a72dcd2b75f40172fc6bf2 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 01:34:24 +0300 Subject: [PATCH 157/675] fix!: reply 413 to the limits and 400 to a malformed body in multipart Every refusal was a `415 Unsupported Media Type`, which tells a client to change the media type it sends: a body over a limit of busboy (`fields`, `files`, `parts`, `fieldSize`, `fileSize`) and a malformed body alike. A limit is now a `413 Content Too Large` (as `limit` already was), and a malformed body, or a `multipart/form-data` without a valid boundary (RFC 2046), a `400 Bad Request`. A field in a charset that busboy cannot decode is a `415`: it used to become the string "undefined". Another media type is still skipped without reading its body, so that another parser can take it. After any refusal the request is no longer read, nor parsed, but the connection is kept: the client that is still sending its body receives the status, with `Connection: close`, and the connection is closed after the reply. Only a response that has already started destroys the request. The middleware stops at once when `context.signal` is aborted (a client that has gone away, the deadline): `run()` rejects with a `400` of code `aborted`, also when the request was aborted before it ran. A `Content-Length` over `limit` is refused before the body is read, the parts of a body are kept in their order (a file after a later field), a part without a name is named `""` (instead of "undefined"), a second `Multipart` on the same request skips it instead of failing on a consumed body, and the middleware logs through `context.log`, as `Multipart`. BREAKING CHANGE: a body over a limit of `config.limits` gets a `413` instead of a `415`, a malformed body or boundary a `400` instead of a `415`, and a `multipart/form-data` request without a boundary a `400` instead of being skipped. An aborted request rejects with a `400` of code `aborted`. Co-Authored-By: Claude Opus 5.5 --- packages/multipart/index.ts | 172 +---- packages/multipart/src/multipart.ts | 400 ++++++++++ packages/multipart/test/aborted.test.ts | 98 +++ packages/multipart/test/helpers.ts | 275 +++++++ packages/multipart/test/malformed.test.ts | 162 ---- packages/multipart/test/multipart.test.ts | 880 +++++++++++----------- 6 files changed, 1230 insertions(+), 757 deletions(-) create mode 100644 packages/multipart/src/multipart.ts create mode 100644 packages/multipart/test/aborted.test.ts create mode 100644 packages/multipart/test/helpers.ts delete mode 100644 packages/multipart/test/malformed.test.ts diff --git a/packages/multipart/index.ts b/packages/multipart/index.ts index dd77402a..cac77d56 100644 --- a/packages/multipart/index.ts +++ b/packages/multipart/index.ts @@ -1,173 +1,7 @@ -import { - BindenError, - type Context, - type IMiddlewareParams, - Middleware, -} from "binden"; -import busboy, { type BusboyConfig } from "busboy"; -import { finished } from "node:stream"; +import { type IMultipartConfig, Multipart } from "./src/multipart.js"; -export type IMultipartConfig = Omit; - -export interface IMultipartOptions extends IMiddlewareParams { - config?: IMultipartConfig; - throw_limits?: boolean; - /** - * The largest body in bytes, all of its parts included (no limit by - * default): a larger one is not read further, and gets a - * `413 Content Too Large` - */ - limit?: number; -} - -export class Multipart extends Middleware { - readonly #config: IMultipartConfig; - readonly #throw_limits: boolean; - readonly #limit: number | null; - - public constructor({ - config = {}, - throw_limits = true, - limit, - ...rest - }: IMultipartOptions = {}) { - super(rest); - if ( - typeof limit !== "undefined" && - !(Number.isSafeInteger(limit) && limit >= 0) - ) { - throw new TypeError("`limit` is not a non-negative integer"); - } - this.#config = config; - this.#throw_limits = throw_limits; - this.#limit = limit ?? null; - } - - public async run(context: Context): Promise { - const { log, request } = context; - const { headers, method, content_type } = request; - - /* eslint-disable @typescript-eslint/switch-exhaustiveness-check */ - switch (method) { - case "GET": - case "HEAD": - case "OPTIONS": - case "TRACE": - log.trace("Method does not support incoming body", { method }); - return; - default: - break; - } - - if ( - typeof content_type?.boundary === "undefined" || - content_type.boundary === null - ) { - log.trace("`Content-Type` is not `multipart/form-data`", { - "Content-Type": content_type?.type, - }); - return; - } - - await new Promise((resolve, reject) => { - const fd = new FormData(); - let size = 0; - let tooLarge = false; - const limit = this.#limit; - const bb = busboy({ headers, ...this.#config }) - .once("error", (cause) => { - const message = "`busboy` internal error"; - reject(new BindenError(415, { message, cause })); - }) - .once("close", () => { - if (!tooLarge) { - request.body = fd; - resolve(); - } - }) - .on("field", (name, value, { valueTruncated }) => { - // `nameTruncated` is never set for `multipart/form-data` (busboy applies - // `fieldNameSize` to `application/x-www-form-urlencoded` only) - if (this.#throw_limits && valueTruncated) { - const cause = new Error("`busboy` reached `fieldSize` limit "); - const error = new BindenError(415, { cause }); - reject(error); - } else { - fd.append(name, value); - } - }) - .on("file", (name, stream, { mimeType: type, filename }) => { - const chunks: Buffer[] = []; - stream - .once("error", (cause) => { - const message = "`busboy` file stream error"; - reject(new BindenError(415, { message, cause })); - }) - .once("close", () => { - // @types/busboy 1.5.5 types `filename` as optional; the value is passed through unchanged - fd.append(name, new File(chunks, filename as string, { type })); - }) - .on("data", (chunk: Buffer) => { - chunks.push(chunk); - }) - .once("limit", () => { - if (this.#throw_limits) { - const cause = new Error("`busboy` reached `fileSize` limit "); - const error = new BindenError(415, { cause }); - reject(error); - } - }); - }) - .on("fieldsLimit", () => { - if (this.#throw_limits) { - const cause = new Error("`busboy` reached fields limit "); - const error = new BindenError(415, { cause }); - reject(error); - } - }) - .on("filesLimit", () => { - if (this.#throw_limits) { - const cause = new Error("`busboy` reached files limit "); - const error = new BindenError(415, { cause }); - reject(error); - } - }) - .once("partsLimit", () => { - if (this.#throw_limits) { - const cause = new Error("`busboy` reached parts limit "); - const error = new BindenError(415, { cause }); - reject(error); - } else { - request.body = fd; - resolve(); - } - }); - // `pipe` does not forward a closed connection to busboy: destroy it, so - // that its `error` rejects this promise - finished(request, (error) => { - if (error) { - bb.destroy(error); - } - }); - if (limit !== null) { - request.on("data", (chunk: Buffer) => { - size += chunk.byteLength; - if (size > limit && !tooLarge) { - // Not read further: the connection is closed after the reply - tooLarge = true; - log.debug("The body is too large", { limit }); - request.unpipe(bb); - request.pause(); - bb.destroy(); - context.setHeader("Connection", "close"); - reject(new BindenError(413)); - } - }); - } - request.pipe(bb); - }); - } -} +export * from "./src/multipart.js"; +/** A `Multipart` with the configuration of busboy */ export default (config: IMultipartConfig = {}): Multipart => new Multipart({ config }); diff --git a/packages/multipart/src/multipart.ts b/packages/multipart/src/multipart.ts new file mode 100644 index 00000000..2ee20dcf --- /dev/null +++ b/packages/multipart/src/multipart.ts @@ -0,0 +1,400 @@ +import { + BindenError, + type Context, + type IMiddlewareParams, + Middleware, +} from "binden"; +import busboy, { + type Busboy, + type BusboyConfig, + type FieldInfo, + type FileInfo, +} from "busboy"; +import type { Readable } from "node:stream"; + +/** The configuration of busboy (its `headers` are those of the request) */ +export type IMultipartConfig = Omit; + +/** The options of `Multipart` */ +export interface IMultipartOptions extends IMiddlewareParams { + /** The configuration of busboy, e.g. its `limits` */ + config?: IMultipartConfig | undefined; + /** + * Whether a request that reaches a limit of `config.limits` is refused + * with `413 Content Too Large` (`true`), instead of truncated + */ + throw_limits?: boolean | undefined; + /** + * The largest body in bytes, all of its parts included (no limit by + * default): a larger one is not read further, and gets a + * `413 Content Too Large`, whatever `throw_limits` + */ + limit?: number | undefined; +} + +/** The logger of a context */ +type ILog = Context["log"]; + +/** A file stream of busboy */ +type IFileStream = Readable & { truncated?: boolean }; + +/** A part, in the order of the request (a file once it has been read) */ +interface ISlot { + readonly name: string; + value: File | string | null; +} + +/** The methods whose requests have no body to parse */ +const methods: ReadonlySet = new Set([ + "GET", + "HEAD", + "OPTIONS", + "TRACE", +]); + +/** A boundary (RFC 2046, section 5.1.1): 1 to 70 characters, without a trailing space */ +const boundaries = /^[\w'()+,\-./:=? ]{0,69}[\w'()+,\-./:=?]$/u; + +/** The requests that have been parsed, or are being parsed */ +const parsed = new WeakSet(); + +/** Observes an error that is reported otherwise */ +function ignore(): void { + // Observed +} + +/** The media type of a `Content-Type` header, in lowercase */ +function mediaType(header: string | undefined): string { + return (header ?? "").split(";", 1)[0].trim().toLowerCase(); +} + +/** Busboy for a request, or the error of its configuration */ +function create(config: BusboyConfig): Busboy | Error { + try { + return busboy(config); + } catch (error: unknown) { + return error as Error; + } +} + +/** The content of a file */ +async function collect(stream: Readable): Promise[]> { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(chunk as Buffer); + } + return chunks; +} + +/** + * The parse of one request: its body is read until it is complete, or until + * it is refused (once) + */ +class Parser { + readonly #context: Context; + readonly #log: ILog; + readonly #config: IMultipartConfig; + readonly #throw_limits: boolean; + readonly #limit: number; + readonly #deferred = Promise.withResolvers(); + readonly #slots: ISlot[] = []; + readonly #tasks: Promise[] = []; + readonly #streams = new Set(); + readonly #detach: (() => void)[] = []; + #busboy: Busboy | null = null; + #over = false; + + public constructor( + context: Context, + log: ILog, + { + config, + throw_limits, + limit, + }: { config: IMultipartConfig; throw_limits: boolean; limit: number }, + ) { + this.#context = context; + this.#log = log; + this.#config = config; + this.#throw_limits = throw_limits; + this.#limit = limit; + } + + /** Settles once the body is parsed (`request.body`), or refused */ + public run(): Promise { + const { request, signal } = this.#context; + if (signal.aborted) { + this.#abort(); + return this.#deferred.promise; + } + const onAbort = (): void => { + this.#abort(); + }; + signal.addEventListener("abort", onAbort, { once: true }); + this.#detach.push(() => { + signal.removeEventListener("abort", onAbort); + }); + + const boundary = request.content_type?.boundary ?? ""; + if (!boundaries.test(boundary)) { + // Busboy would not find the parts + const message = "The `boundary` is missing or invalid"; + this.#refuse(new BindenError(400, { message })); + return this.#deferred.promise; + } + + const length = Number(request.headers["content-length"] ?? 0); + if (length > this.#limit) { + this.#tooLarge(); + return this.#deferred.promise; + } + + const parser = create({ ...this.#config, headers: request.headers }); + if (parser instanceof Error) { + this.#malformed(parser); + return this.#deferred.promise; + } + this.#busboy = parser; + parser + .on("error", (cause: unknown) => { + this.#malformed(cause); + }) + .on( + "field", + (name: string | undefined, value: string | undefined, info) => { + this.#onField(name, value, info); + }, + ) + .on("file", (name: string | undefined, stream, info) => { + this.#onFile(name, stream, info); + }) + .once("fieldsLimit", () => { + this.#limitReached("fields"); + }) + .once("filesLimit", () => { + this.#limitReached("files"); + }) + .once("partsLimit", () => { + this.#limitReached("parts"); + }) + .once("close", () => { + Promise.all(this.#tasks).then(() => { + this.#complete(); + }, ignore); + }); + + if (this.#limit !== Number.POSITIVE_INFINITY) { + let received = 0; + const count = (chunk: Buffer): void => { + received += chunk.byteLength; + if (received > this.#limit) { + this.#tooLarge(); + } + }; + // Before busboy, which does not see what is over the limit + request.on("data", count); + this.#detach.push(() => { + request.off("data", count); + }); + } + // A closed connection aborts the signal (`pipe` does not tell busboy) + request.pipe(parser); + return this.#deferred.promise; + } + + #onField( + name: string | undefined, + value: string | undefined, + { valueTruncated }: FieldInfo, + ): void { + if (this.#over) { + return; + } else if (typeof value === "undefined") { + // Busboy decodes only some charsets (e.g. not `koi8-r`) + const message = "The charset of a field is not supported"; + this.#refuse(new BindenError(415, { message })); + return; + } else if (valueTruncated && this.#throw_limits) { + this.#limitReached("fieldSize"); + return; + } + // A part without a name (or with an empty one) is named `""` + this.#slots.push({ name: name ?? "", value }); + } + + #onFile( + name: string | undefined, + stream: IFileStream, + { filename, mimeType }: FileInfo, + ): void { + // Observed: busboy reports the errors of its streams itself + stream.on("error", ignore); + if (this.#over) { + stream.resume(); + return; + } + this.#streams.add(stream); + stream.once("close", () => { + this.#streams.delete(stream); + }); + stream.once("limit", () => { + this.#limitReached("fileSize"); + }); + const slot: ISlot = { name: name ?? "", value: null }; + this.#slots.push(slot); + this.#tasks.push( + collect(stream).then( + (chunks) => { + slot.value = new File(chunks, String(filename), { type: mimeType }); + }, + // The parser reports why the stream has failed + ignore, + ), + ); + } + + /** A limit of busboy (refused with `throw_limits`, otherwise truncated) */ + #limitReached(limit: string): void { + if (this.#throw_limits) { + const message = `The \`${limit}\` limit has been reached`; + this.#refuse(new BindenError(413, { message })); + } + } + + #tooLarge(): void { + const message = "The body is larger than `limit`"; + this.#refuse(new BindenError(413, { message })); + } + + #malformed(cause: unknown): void { + const message = "The body is not a valid `multipart/form-data`"; + this.#refuse(new BindenError(400, { message, cause })); + } + + /** + * The request is aborted (see `context.signal`): e.g. the client has gone + * away, or the deadline has passed + */ + #abort(): void { + const message = "The request has been aborted"; + const cause: unknown = this.#context.signal.reason; + this.#refuse(new BindenError(400, { message, code: "aborted", cause })); + } + + /** + * Refuses the request: the body is not read further, nor parsed. The + * connection is kept until the reply to the error, which closes it, + * unless its response has already started. + */ + #refuse(error: BindenError): void { + if (this.#over) { + return; + } + this.#end(); + const { request, response } = this.#context; + if (this.#busboy) { + request.unpipe(this.#busboy); + } + if (response.started) { + // The reply cannot tell the error + request.destroy(); + } else { + request.pause(); + if (!response.headersSent) { + response.setHeader("Connection", "close"); + } + } + // Not while busboy emits the event that has refused the request: it would + // go on with a stream that it no longer has + process.nextTick(() => { + this.#busboy?.destroy(); + for (const stream of this.#streams) { + stream.destroy(); + } + }); + this.#log.debug("The multipart body has been refused", { + status: error.status, + }); + this.#deferred.reject(error); + } + + /** The body has been parsed: every file has been read */ + #complete(): void { + if (this.#over) { + return; + } + this.#end(); + const form = new FormData(); + for (const { name, value } of this.#slots) { + if (value !== null) { + form.append(name, value); + } + } + this.#context.request.body = form; + this.#deferred.resolve(null); + } + + #end(): void { + this.#over = true; + for (const detach of this.#detach) { + detach(); + } + } +} + +/** + * Parses a `multipart/form-data` body (with busboy) into `request.body`; a + * request of another media type is skipped (its body is not read) + */ +export class Multipart extends Middleware { + readonly #config: IMultipartConfig; + readonly #throw_limits: boolean; + readonly #limit: number; + + public constructor({ + config = {}, + throw_limits = true, + limit, + ...rest + }: IMultipartOptions = {}) { + super(rest); + if ( + typeof limit !== "undefined" && + !(Number.isSafeInteger(limit) && limit >= 0) + ) { + throw new TypeError("`limit` is not a non-negative integer"); + } + this.#config = config; + this.#throw_limits = throw_limits; + this.#limit = limit ?? Number.POSITIVE_INFINITY; + } + + /** + * Parses the body of a `multipart/form-data` request: a limit gives a + * `413`, and a malformed body (or boundary) a `400` + */ + public async run(context: Context): Promise { + const { request } = context; + const log = context.log.child({ middleware: Multipart.name }); + const { method = "GET", headers } = request; + const type = mediaType(headers["content-type"]); + + if (methods.has(method)) { + log.debug("The method has no body to parse", { method }); + return; + } else if (type !== "multipart/form-data") { + log.debug("The body is not `multipart/form-data`", { type }); + return; + } else if (parsed.has(request)) { + log.debug("The body has already been parsed"); + return; + } + parsed.add(request); + + await new Parser(context, log, { + config: this.#config, + throw_limits: this.#throw_limits, + limit: this.#limit, + }).run(); + } +} diff --git a/packages/multipart/test/aborted.test.ts b/packages/multipart/test/aborted.test.ts new file mode 100644 index 00000000..2409295d --- /dev/null +++ b/packages/multipart/test/aborted.test.ts @@ -0,0 +1,98 @@ +import { BindenAbortError, BindenError, type Context } from "binden"; +import { deepEqual, ok } from "node:assert/strict"; +import { describe, it } from "node:test"; +import { part, serve, sleep, type, upload, within } from "./helpers.js"; +import { Multipart } from "../index.js"; +import { request as httpRequest } from "node:http"; +import { once } from "node:events"; + +/** What `run()` has settled with */ +function outcome(error: unknown): unknown[] { + ok(error instanceof BindenError); + const { cause } = error; + return [ + error.status, + error.code, + cause instanceof BindenAbortError ? cause.reason : typeof cause, + ]; +} + +describe("Multipart (aborted request)", () => { + it("rejects once the connection is closed in the middle of a file", async (t) => { + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const { promise: started, resolve: start } = Promise.withResolvers(); + const parser = new Multipart(); + const { url } = await serve(t, [ + async (context: Context): Promise => { + start(null); + await parser.run(context).then(settle, settle); + }, + ]); + + const client = httpRequest(url, { + method: "POST", + headers: { "Content-Type": type, "Content-Length": 1 << 20 }, + }); + client.on("error", () => { + // The connection is closed on purpose + }); + client.write(part('name="file"; filename="file.txt"', "Hello")); + await within(started); + await sleep(20); + client.destroy(); + + deepEqual(outcome(await within(settled)), [ + 400, + "aborted", + "client_closed", + ]); + }); + + it("stops reading once the deadline has passed", async (t) => { + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const parser = new Multipart(); + const { url } = await serve( + t, + [ + async (context: Context): Promise => { + await parser.run(context).catch((error: unknown) => { + settle(error); + throw error; + }); + }, + ], + { timeout: 100 }, + ); + + const reply = await upload( + url, + part('name="file"; filename="file.txt"', "Hello"), + ); + + deepEqual(reply, [503, "close"]); + deepEqual(outcome(await within(settled)), [400, "aborted", "deadline"]); + }); + + it("rejects at once when the request has already been aborted", async (t) => { + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const parser = new Multipart(); + const { url } = await serve( + t, + [ + async (context: Context): Promise => { + await once(context.signal, "abort"); + await parser.run(context).then(settle, settle); + }, + ], + { timeout: 50 }, + ); + + const reply = await upload(url, part('name="a"', "1")); + + deepEqual(reply[0], 503); + deepEqual(outcome(await within(settled)), [400, "aborted", "deadline"]); + }); +}); diff --git a/packages/multipart/test/helpers.ts b/packages/multipart/test/helpers.ts new file mode 100644 index 00000000..692af65a --- /dev/null +++ b/packages/multipart/test/helpers.ts @@ -0,0 +1,275 @@ +import { + Agent, + type IncomingHttpHeaders, + type OutgoingHttpHeaders, + type Server, + request as httpRequest, +} from "node:http"; +import { Binden, type IBindenOptions, type IMiddleware } from "binden"; +import type { AddressInfo } from "node:net"; +import type { TestContext } from "node:test"; +import { once } from "node:events"; + +/** A response as the client receives it */ +export interface IReply { + readonly status: number; + readonly headers: IncomingHttpHeaders; + readonly body: string; +} + +/** A server of an app, on a random port of `localhost` */ +export interface ITestServer { + readonly url: string; + readonly server: Server; + close(): Promise; +} + +/** Starts the server of `app` */ +export async function listen(app: Binden): Promise { + const server = app.createServer().listen(0, "localhost"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + return { + url: `http://localhost:${port}/`, + server, + async close(): Promise { + server.closeAllConnections(); + server.close(); + await once(server, "close"); + }, + }; +} + +/** Starts the server of an app of `middlewares`, closed after the test */ +export async function serve( + t: TestContext, + middlewares: readonly IMiddleware[], + options: IBindenOptions = {}, +): Promise { + const test = await listen(new Binden(options).use(...middlewares)); + t.after(() => test.close()); + return test; +} + +/** Resolves after `ms` milliseconds */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** A promise that never settles (e.g. a callback that hangs) */ +export function never(): Promise { + return new Promise(() => { + // Never settles + }); +} + +/** The result of a promise, which must settle within `ms` milliseconds */ +export async function within(promise: Promise, ms = 5000): Promise { + const { promise: expired, reject } = Promise.withResolvers(); + const timer = setTimeout(() => { + reject(new Error(`Not settled within ${ms} ms`)); + }, ms); + try { + return await Promise.race([promise, expired]); + } finally { + clearTimeout(timer); + } +} + +/** A boundary for the bodies of the tests */ +export const boundary = "binden-boundary"; + +/** The `Content-Type` of the bodies of the tests */ +export const type = `multipart/form-data; boundary=${boundary}`; + +/** A part of a body: its headers, and its content */ +export function part( + disposition: string, + content: string, + headers: readonly string[] = [], +): string { + return [ + `--${boundary}`, + `Content-Disposition: form-data; ${disposition}`, + ...headers, + "", + content, + ].join("\r\n"); +} + +/** A complete body of parts */ +export function body(...parts: string[]): string { + return `${[...parts, `--${boundary}--`].join("\r\n")}\r\n`; +} + +/** A body as `fetch()` encodes a form, and its `Content-Type` */ +export async function encode( + form: FormData, +): Promise<{ body: Buffer; type: string }> { + const encoded = new Request("http://localhost/", { + method: "POST", + body: form, + }); + return { + body: Buffer.from(await encoded.arrayBuffer()), + type: encoded.headers.get("Content-Type") ?? "", + }; +} + +/** The options of `send()` */ +export interface ISendOptions { + readonly method?: string; + readonly headers?: OutgoingHttpHeaders; + readonly body?: Buffer | string; + /** The agent of the connection (by default, one of its own that keeps it alive) */ + readonly agent?: Agent; +} + +/** A request and its response, within 5 seconds */ +function exchange( + url: string, + agent: Agent, + { method, headers, body: data }: Required>, +): Promise { + const signal = AbortSignal.timeout(5000); + return new Promise((resolve, reject) => { + httpRequest(url, { agent, method, headers, signal }) + .once("error", reject) + .once("response", (response) => { + const chunks: Buffer[] = []; + response + .on("data", (chunk: Buffer) => { + chunks.push(chunk); + }) + .once("error", reject) + .once("end", () => { + resolve({ + status: response.statusCode ?? 0, + headers: response.headers, + body: Buffer.concat(chunks).toString(), + }); + }); + }) + .end(data); + }); +} + +/** + * Sends a request and settles with its response, within 5 seconds. The + * connection is kept alive unless the server closes it (its `Connection`). + */ +export async function send( + url: string, + { + method = "POST", + headers = {}, + body: data = "", + agent: shared, + }: ISendOptions = {}, +): Promise { + const agent = shared ?? new Agent({ keepAlive: true }); + try { + return await exchange(url, agent, { method, headers, body: data }); + } finally { + if (!shared) { + agent.destroy(); + } + } +} + +/** Sends a body of parts (see `body()`) */ +export function post( + url: string, + data: Buffer | string, + headers: OutgoingHttpHeaders = {}, +): Promise { + return send(url, { + headers: { "Content-Type": type, ...headers }, + body: data, + }); +} + +/** + * Starts a request whose body is never complete (its `Content-Length` is + * larger), and settles with the status and `Connection` of its response + */ +export function upload(url: string, ...chunks: string[]): Promise { + return new Promise((resolve, reject) => { + const client = httpRequest(url, { + method: "POST", + headers: { "Content-Type": type, "Content-Length": 1 << 20 }, + signal: AbortSignal.timeout(5000), + }); + client + .once("response", (response) => { + response.resume(); + resolve([response.statusCode, response.headers.connection]); + client.destroy(); + }) + .once("error", reject); + for (const chunk of chunks) { + client.write(chunk); + } + }); +} + +/** A logged record */ +export interface IRecord { + readonly level: string; + readonly message: string; + readonly bindings: Record; + readonly extra?: Record | undefined; +} + +type ILevel = "debug" | "error" | "fatal" | "info" | "trace" | "warn"; + +/** A logger that keeps its records (and the bindings of its children) */ +export class RecordingLogger { + public readonly records: IRecord[]; + readonly #bindings: Record; + + public constructor( + records: IRecord[] = [], + bindings: Record = {}, + ) { + this.records = records; + this.#bindings = bindings; + } + + public fatal(message: string, extra?: Record): void { + this.#log("fatal", message, extra); + } + + public error(message: string, extra?: Record): void { + this.#log("error", message, extra); + } + + public warn(message: string, extra?: Record): void { + this.#log("warn", message, extra); + } + + public info(message: string, extra?: Record): void { + this.#log("info", message, extra); + } + + public debug(message: string, extra?: Record): void { + this.#log("debug", message, extra); + } + + public trace(message: string, extra?: Record): void { + this.#log("trace", message, extra); + } + + public child(bindings: Record): RecordingLogger { + return new RecordingLogger(this.records, { + ...this.#bindings, + ...bindings, + }); + } + + #log(level: ILevel, message: string, extra?: Record): void { + this.records.push({ level, message, bindings: this.#bindings, extra }); + } +} diff --git a/packages/multipart/test/malformed.test.ts b/packages/multipart/test/malformed.test.ts deleted file mode 100644 index 22946696..00000000 --- a/packages/multipart/test/malformed.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -/* eslint-disable init-declarations */ -import { Binden, type Context } from "binden"; -import { type Server, request } from "node:http"; -import { afterEach, beforeEach, describe, it } from "node:test"; -import { deepEqual, ok } from "node:assert/strict"; -import type { AddressInfo } from "node:net"; -import { Multipart } from "../index.js"; - -let url = ""; - -function post(body: string, boundary: string): Promise { - return new Promise((resolve, reject) => { - request(url, { - method: "POST", - headers: { - "Content-Type": `multipart/form-data; boundary=${boundary}`, - "Content-Length": Buffer.byteLength(body), - }, - }) - .once("error", reject) - .once("response", (response) => { - response.resume(); - resolve(response.statusCode ?? 0); - }) - .end(body); - }); -} - -describe("Multipart (malformed body)", () => { - let server: Server; - - beforeEach(async () => { - const app = new Binden().use(new Multipart(), (ct: Context) => - ct.send("OK"), - ); - await new Promise((resolve) => { - server = app.createServer().listen(0, () => { - url = `http://localhost:${(server.address() as AddressInfo).port}/`; - resolve(); - }); - }); - }); - - it("Replies with 415 error when a file part is not terminated", async () => { - const boundary = "binden-boundary"; - const truncated = [ - `--${boundary}`, - 'Content-Disposition: form-data; name="file"; filename="file.txt"', - "Content-Type: text/plain", - "", - "Hello World", - ].join("\r\n"); - - deepEqual(await post(truncated, boundary), 415); - - const complete = [ - `--${boundary}`, - 'Content-Disposition: form-data; name="file"; filename="file.txt"', - "Content-Type: text/plain", - "", - "Hello World", - `--${boundary}--`, - "", - ].join("\r\n"); - - deepEqual(await post(complete, boundary), 200); - }); - - afterEach(async () => { - server.closeAllConnections(); - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - }); - }); -}); - -describe("Multipart (aborted request)", () => { - let server: Server; - let started: Promise; - let settled: Promise; - - beforeEach(async () => { - let start: () => void; - let settle: (value: unknown) => void; - started = new Promise((resolve) => { - start = resolve; - }); - settled = new Promise((resolve) => { - settle = resolve; - }); - - const parser = new Multipart(); - const app = new Binden().use(async (ct: Context) => { - start(); - try { - await parser.run(ct); - settle(null); - } catch (error: unknown) { - settle(error); - } - }); - await new Promise((resolve) => { - server = app.createServer().listen(0, () => { - url = `http://localhost:${(server.address() as AddressInfo).port}/`; - resolve(); - }); - }); - }); - - it( - "Settles when the connection is closed in the middle of a file", - { timeout: 5000 }, - async () => { - const boundary = "binden-boundary"; - const head = [ - `--${boundary}`, - 'Content-Disposition: form-data; name="file"; filename="file.txt"', - "Content-Type: text/plain", - "", - "Hello", - ].join("\r\n"); - const req = request(url, { - method: "POST", - headers: { - "Content-Type": `multipart/form-data; boundary=${boundary}`, - "Content-Length": Buffer.byteLength(head) + 1024, - }, - }); - req.on("error", () => { - // The connection is closed on purpose - }); - req.write(head); - - await started; - await new Promise((resolve) => { - setTimeout(resolve, 20); - }); - req.destroy(); - - ok((await settled) instanceof Error); - }, - ); - - afterEach(async () => { - server.closeAllConnections(); - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - }); - }); -}); diff --git a/packages/multipart/test/multipart.test.ts b/packages/multipart/test/multipart.test.ts index 0d2e2cae..7e24d168 100644 --- a/packages/multipart/test/multipart.test.ts +++ b/packages/multipart/test/multipart.test.ts @@ -1,511 +1,539 @@ -/* eslint-disable init-declarations, sort-imports */ -import { deepEqual, ok, throws } from "node:assert/strict"; -import { Agent, request, Server } from "node:http"; -import type { AddressInfo } from "node:net"; -import { afterEach, beforeEach, describe, it } from "node:test"; -import { Binden, Context } from "binden"; +import { Agent, request as httpRequest } from "node:http"; +import { Binden, BindenError, type Context } from "binden"; +import { + RecordingLogger, + body, + encode, + listen, + part, + post, + send, + serve, + type, + upload, +} from "./helpers.js"; +import { deepEqual, equal, ok, throws } from "node:assert/strict"; +import { describe, it } from "node:test"; import multipart, { Multipart } from "../index.js"; -let url = ""; +/** The entries of a form: a file as its name, type and content */ +async function entries(form: unknown): Promise { + ok(form instanceof FormData); + const result: unknown[] = []; + for (const [name, value] of form) { + result.push( + typeof value === "string" + ? [name, value] + : [name, value.name, value.type, await value.text()], + ); + } + return result; +} + +/** A middleware that keeps the entries of the parsed form */ +function keep(parsed: unknown[]): (context: Context) => Promise { + return async (context: Context): Promise => { + parsed.push(...(await entries(context.request.body))); + await context.send(); + }; +} + +/** The limits of busboy, and a body that reaches each of them */ +const limits = [ + ["fields", { fields: 1 }, [part('name="a"', "1"), part('name="b"', "2")]], + [ + "files", + { files: 1 }, + [part('name="a"; filename="a"', "1"), part('name="b"; filename="b"', "2")], + ], + ["parts", { parts: 1 }, [part('name="a"', "1"), part('name="b"', "2")]], + ["fieldSize", { fieldSize: 3 }, [part('name="a"', "abcdef")]], + ["fileSize", { fileSize: 3 }, [part('name="a"; filename="a"', "abcdef")]], +] as const; describe("Multipart", () => { - let app: Binden; - let server: Server; - - beforeEach(async () => { - app = new Binden(); - await new Promise((resolve) => { - server = app.createServer().listen(0, () => { - url = `http://localhost:${(server.address() as AddressInfo).port}/`; - resolve(); - }); - }); + it("parses the fields and the files of a form, in their order", async (t) => { + const form = new FormData(); + form.append("name1", "value11"); + form.append( + "file1", + new File(['{"hello":"world"}'], "file11.json", { + type: "application/json", + }), + ); + form.append("name1", "value12"); + form.append( + "file1", + new File([""], "file12.html", { type: "text/html" }), + ); + form.append("name2", "value2"); + form.append( + "file2", + new File(["Hello World"], "file2.txt", { type: "text/plain" }), + ); + const parsed: unknown[] = []; + const { url } = await serve(t, [multipart(), keep(parsed)]); + + const { body: data, type: content_type } = await encode(form); + const reply = await post(url, data, { "Content-Type": content_type }); + + equal(reply.status, 200); + deepEqual(parsed, [ + ["name1", "value11"], + ["file1", "file11.json", "application/json", '{"hello":"world"}'], + ["name1", "value12"], + ["file1", "file12.html", "text/html", ""], + ["name2", "value2"], + ["file2", "file2.txt", "text/plain", "Hello World"], + ]); }); - it("`limit` (the size of the body, all parts included)", async () => { - const received: unknown[] = []; - app.use(new Multipart({ limit: 1024 }), async (ct: Context) => { - received.push((ct.request.body as FormData).get("field")); - await ct.send(); + it("parses a `boundary` that comes after another parameter", async (t) => { + const parsed: unknown[] = []; + const { url } = await serve(t, [multipart(), keep(parsed)]); + + const reply = await post(url, body(part('name="field"', "value")), { + "Content-Type": `multipart/form-data; charset=utf-8; boundary="binden-boundary"`, }); - // Keeps its connections (unlike the global agent of the tests) - const agent = new Agent({ keepAlive: true }); - const send = async (form: FormData): Promise => { - const encoded = new Request(url, { method: "POST", body: form }); - const body = Buffer.from(await encoded.arrayBuffer()); - const type = encoded.headers.get("Content-Type") ?? ""; - return new Promise((resolve, reject) => { - request(url, { - agent, - method: "POST", - headers: { "Content-Type": type }, - }) - .on("response", (response) => { - response.resume().once("end", () => { - resolve([response.statusCode, response.headers.connection]); - }); - }) - .on("error", reject) - .end(body); - }); - }; - const small = new FormData(); - small.append("field", "value"); - const large = new FormData(); - large.append("field", "value"); - large.append("file", new File([new Uint8Array(1024)], "file.bin")); - - deepEqual(await send(small), [200, "keep-alive"]); - // Not read further: the connection is closed after the reply - deepEqual(await send(large), [413, "close"]); - deepEqual(received, ["value"]); - agent.destroy(); - }); - it("`limit` (invalid)", () => { - for (const limit of [-1, 0.5, Number.NaN]) { - throws( - () => new Multipart({ limit }), - new TypeError("`limit` is not a non-negative integer"), - ); - } + equal(reply.status, 200); + deepEqual(parsed, [["field", "value"]]); }); - it("Skips when method is `GET`", async () => { - const message = { success: true }; - const assert = async (ct: Context): Promise => { - ok(ct.request.method === "GET"); - ok(typeof ct.request.body === "undefined"); - await ct.json(message); - }; + it('names a part without a name `""`', async (t) => { + const parsed: unknown[] = []; + const { url } = await serve(t, [multipart(), keep(parsed)]); - app.use(new Multipart(), assert); + const reply = await post( + url, + body(part('name=""', "value"), part('filename="a.txt"', "file")), + ); - const response = await fetch(url); - const data = (await response.json()) as unknown; - deepEqual(response.status, 200); - deepEqual(data, message); + equal(reply.status, 200); + deepEqual(parsed, [ + ["", "value"], + ["", "a.txt", "text/plain", "file"], + ]); }); - it("Skips when method is `HEAD`", async () => { - const assert = async (ct: Context): Promise => { - ok(ct.request.method === "HEAD"); - ok(typeof ct.request.body === "undefined"); - await ct.send(); - }; - - app.use(new Multipart(), assert); + it("skips the methods without a body", async (t) => { + const bodies: unknown[] = []; + const { url } = await serve(t, [ + multipart(), + async (context: Context): Promise => { + bodies.push(typeof context.request.body); + await context.send(); + }, + ]); - const response = await fetch(url, { method: "HEAD" }); - const data = (await response.text()) as unknown; - deepEqual(response.status, 200); - deepEqual(data, ""); + for (const method of ["GET", "HEAD", "OPTIONS", "TRACE"]) { + const reply = await send(url, { + method, + headers: { "Content-Type": type }, + }); + equal(reply.status, 200); + } + deepEqual(bodies, Array(4).fill("undefined")); }); - it("Skips when no `Content-Type` header is present", async () => { - const body = "Hello World"; - const assert = async (ct: Context): Promise => { - ok(typeof ct.request.headers["content-type"] === "undefined"); - ok(typeof ct.request.body === "undefined"); - await ct.send(); - }; - - app.use(multipart(), assert); - - await new Promise((resolve, reject) => { - request(url, { method: "POST" }) - .on("response", (response) => { - const chunks: Buffer[] = []; - response - .on("data", (chunk: Buffer) => { - chunks.push(chunk); - }) - .on("error", reject) - .on("end", () => { - try { - deepEqual(Buffer.concat(chunks).toString(), ""); - ok(response.statusCode === 200); - resolve(); - } catch (error) { - reject(error as Error); - } - }); - }) - .on("error", reject) - .end(body); - }); + it("skips another media type, without reading its body", async (t) => { + const read: unknown[] = []; + const { url } = await serve(t, [ + multipart(), + async (context: Context): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of context.request) { + chunks.push(chunk as Buffer); + } + const text = Buffer.concat(chunks).toString(); + read.push([typeof context.request.body, text]); + await context.send(); + }, + ]); + + const data = body(part('name="field"', "value")); + for (const content_type of [ + null, + "text/plain", + "application/json; charset=utf-8", + `multipart/mixed; boundary=binden-boundary`, + `multipart/form-data-like; boundary=binden-boundary`, + ]) { + const headers = + content_type === null ? {} : { "Content-Type": content_type }; + const reply = await send(url, { headers, body: data }); + equal(reply.status, 200); + } + deepEqual(read, Array(5).fill(["undefined", data])); }); - it("Skips when `Content-Type` is not `multipart/form-data`", async () => { - const body = "Hello World"; - const ct = "image/jpeg"; - const assert = async (context: Context): Promise => { - deepEqual(context.request.headers["content-type"], ct); - ok(typeof context.request.body === "undefined"); - await context.send(); - }; - - app.use(multipart(), assert); - - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": ct }, - body, - }); - const data = (await response.text()) as unknown; - deepEqual(response.status, 200); - deepEqual(data, ""); - }); + it("skips a request that has already been parsed", async (t) => { + const parsed: unknown[] = []; + const { url } = await serve(t, [ + new Multipart(), + new Multipart(), + keep(parsed), + ]); - it("Skips when `multipart/form-data` is missing `boundary`", async () => { - const body = "Hello World"; - const form_data = "multipart/form-data"; - const assert = async (ct: Context): Promise => { - deepEqual(ct.request.headers["content-type"], form_data); - ok(typeof ct.request.body === "undefined"); - await ct.send(); - }; - - app.use(multipart(), assert); - - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": form_data }, - body, - }); - const data = (await response.text()) as unknown; - deepEqual(response.status, 200); - deepEqual(data, ""); + const reply = await post(url, body(part('name="field"', "value"))); + + equal(reply.status, 200); + deepEqual(parsed, [["field", "value"]]); }); - it("Parses body as `FormData`", async () => { - const msg = { success: true }; - const body = new FormData(); - body.append("name1", "value11"); - body.append("name1", "value12"); - body.append("name2", "value2"); - const file11 = new File( - [JSON.stringify({ hello: "world" })], - "file11.json", - { type: "application/json" }, - ); - const file12 = new File([""], "file12.html", { - type: "plain/html", + it("replies `400` to a missing or invalid `boundary`", async (t) => { + let reached = false; + const { url } = await serve(t, [ + multipart(), + (): void => { + reached = true; + }, + ]); + + const data = body(part('name="field"', "value")); + for (const [content_type, used] of [ + ["multipart/form-data", ""], + ["Multipart/Form-Data; boundary=", ""], + ...["a".repeat(71), "binden-boundary ", "binden@boundary"].map( + (invalid) => [`multipart/form-data; boundary="${invalid}"`, invalid], + ), + ]) { + // A body of that boundary, which busboy would parse + const reply = await send(url, { + headers: { "Content-Type": content_type }, + body: data.replaceAll("binden-boundary", used), + }); + deepEqual([reply.status, reply.headers.connection], [400, "close"]); + } + equal(reached, false); + + // A parameter that busboy does not parse + const malformed = await send(url, { + headers: { "Content-Type": `${type}; charset` }, + body: data, }); - const file2 = new File(["Hello World"], "file2.txt", { - type: "plain/txt", + equal(malformed.status, 400); + + // The longest boundary, with characters other than letters and digits + const longest = `${"a".repeat(57)}'()+_,-./:= ?`; + const parsed: unknown[] = []; + const other = await serve(t, [multipart(), keep(parsed)]); + const reply = await send(other.url, { + headers: { "Content-Type": `multipart/form-data; boundary="${longest}"` }, + body: data.replaceAll("binden-boundary", longest), }); - body.append("file1", file11); - body.append("file1", file12); - body.append("file2", file2); - - const assert = async (context: Context): Promise => { - deepEqual(context.request.content_type?.type, "multipart/form-data"); - ok(context.request.content_type.boundary !== null); - const { body: actual } = context.request; - ok(actual instanceof FormData); - deepEqual(actual, body); - for (const key in actual.keys()) { - deepEqual(actual.getAll(key), body.getAll(key)); - } - const [actual_file11, actual_file12] = actual.getAll("file1") as File[]; - deepEqual(actual_file11.name, file11.name); - deepEqual(actual_file11.size, file11.size); - deepEqual(actual_file11.type, file11.type); - deepEqual(actual_file12.name, file12.name); - deepEqual(actual_file12.type, file12.type); - deepEqual(actual_file12.size, file12.size); - const [files2] = actual.getAll("file2") as File[]; - deepEqual(files2.name, file2.name); - deepEqual(files2.type, file2.type); - deepEqual(files2.size, file2.size); - await context.json(msg); - }; - - app.use(multipart(), assert); - - const response = await fetch(url, { method: "POST", body }); - const data = (await response.json()) as unknown; - deepEqual(response.status, 200); - deepEqual(data, msg); + equal(reply.status, 200); + deepEqual(parsed, [["field", "value"]]); }); - it("Parses a `boundary` that comes after another parameter", async () => { - const boundary = "binden-boundary"; - const body = [ - `--${boundary}`, - `Content-Disposition: form-data; name="field"`, - "", - "value", - `--${boundary}--`, - "", - ].join("\r\n"); - let field: unknown = null; - app.use(multipart(), async (context: Context): Promise => { - const { body: parsed } = context.request; - field = parsed instanceof FormData ? parsed.get("field") : null; - await context.send(); - }); - - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": `multipart/form-data; charset=utf-8; boundary=${boundary}`, + it("replies `400` to a malformed body", async (t) => { + let reached = false; + const { url } = await serve(t, [ + multipart(), + (): void => { + reached = true; }, - body, - }); + ]); - deepEqual(response.status, 200); - deepEqual(field, "value"); + for (const data of [ + "Hello World", + "", + body(part('name="field"', "value", ["Content-Type: text/plain\u0001"])), + [`--binden-boundary`, `Content-Disposition: form-data; name="a"`].join( + "\r\n", + ), + part('name="file"; filename="file.txt"', "Hello World"), + ]) { + const reply = await post(url, data); + deepEqual([reply.status, reply.headers.connection], [400, "close"]); + } + equal(reached, false); }); - it("Replies with 415 error when request body is not a valid FormData", async () => { - const ct = "multipart/form-data; boundary=----formdata-undici-064882379910"; + it("replies `415` to a field of an unsupported charset", async (t) => { + const { url } = await serve(t, [multipart()]); - app.use(multipart()); + const reply = await post( + url, + body( + part('name="field"', "value", [ + "Content-Type: text/plain; charset=koi8-r", + ]), + ), + ); - const response = await fetch(url, { - method: "POST", - body: "Hello World", - headers: { "Content-Type": ct }, - }); - const data = await response.text(); - deepEqual(response.status, 415); - deepEqual(data, ""); + deepEqual([reply.status, reply.headers.connection], [415, "close"]); }); - it("Replies with 415 error when FormData reaches the `fields` limit (by default)", async () => { - const body = new FormData(); - body.append("k1", "v1"); - body.append("k2", "v2"); - - app.use(multipart({ limits: { fields: 1 } })); + for (const [name, config, parts] of limits) { + it(`replies \`413\` once the \`${name}\` limit is reached`, async (t) => { + let reached = false; + const { url } = await serve(t, [ + multipart({ limits: config }), + (): void => { + reached = true; + }, + ]); - const response = await fetch(url, { method: "POST", body }); - await response.text(); - deepEqual(response.status, 415); - }); + const reply = await post(url, body(...parts)); - it("Passes an incomplete FormData when it reaches the `fields` limit (`throw_limits = true`)", async () => { - const body = new FormData(); - body.append("k1", "v1"); - body.append("k2", "v2"); + deepEqual([reply.status, reply.headers.connection], [413, "close"]); + equal(reached, false); + }); + } - const assert = (context: Context): Promise => { - ok(context.request.body instanceof FormData); - deepEqual(context.request.body.getAll("k1"), ["v1"]); - ok(context.request.body.get("k2") === null); - return context.send(); - }; + it("truncates the fields to `fieldSize` (`throw_limits: false`)", async (t) => { + const parsed: unknown[] = []; + const { url } = await serve(t, [ + new Multipart({ + config: { limits: { fieldSize: 3 } }, + throw_limits: false, + }), + keep(parsed), + ]); - app.use( - new Multipart({ config: { limits: { fields: 1 } }, throw_limits: false }), - assert, - ); + const reply = await post(url, body(part('name="a"', "abcdef"))); - const response = await fetch(url, { method: "POST", body }); - await response.text(); - deepEqual(response.status, 200); + equal(reply.status, 200); + deepEqual(parsed, [["a", "abc"]]); }); - it("Replies with 415 error when a field reaches the `fieldSize` limit (by default)", async () => { - const body = new FormData(); - body.append("k1", "abcdef"); + it("drops the fields over `fields` (`throw_limits: false`)", async (t) => { + const parsed: unknown[] = []; + const { url } = await serve(t, [ + new Multipart({ config: { limits: { fields: 1 } }, throw_limits: false }), + keep(parsed), + ]); - app.use(multipart({ limits: { fieldSize: 3 } })); + const reply = await post( + url, + body(part('name="a"', "1"), part('name="b"', "2")), + ); - const response = await fetch(url, { method: "POST", body }); - await response.text(); - deepEqual(response.status, 415); + equal(reply.status, 200); + deepEqual(parsed, [["a", "1"]]); }); - it("Passes a truncated field when it reaches the `fieldSize` limit (`throw_limits = false`)", async () => { - const body = new FormData(); - body.append("k1", "abcdef"); + it("drops the files over `files` (`throw_limits: false`)", async (t) => { + const parsed: unknown[] = []; + const { url } = await serve(t, [ + new Multipart({ config: { limits: { files: 1 } }, throw_limits: false }), + keep(parsed), + ]); + + const reply = await post( + url, + body( + part('name="a"; filename="a.txt"', "1"), + part('name="b"; filename="b.txt"', "2"), + ), + ); - const assert = (context: Context): Promise => { - ok(context.request.body instanceof FormData); - deepEqual(context.request.body.getAll("k1"), ["abc"]); - return context.send(); - }; + equal(reply.status, 200); + deepEqual(parsed, [["a", "a.txt", "text/plain", "1"]]); + }); - app.use( + it("truncates the files to `fileSize` (`throw_limits: false`)", async (t) => { + const parsed: unknown[] = []; + const { url } = await serve(t, [ new Multipart({ - config: { limits: { fieldSize: 3 } }, + config: { limits: { fileSize: 3 } }, throw_limits: false, }), - assert, + keep(parsed), + ]); + + const reply = await post( + url, + body( + part('name="a"; filename="a.txt"', "abcdef"), + part('name="b"; filename="b.txt"', "ab"), + ), ); - const response = await fetch(url, { method: "POST", body }); - await response.text(); - deepEqual(response.status, 200); + equal(reply.status, 200); + deepEqual(parsed, [ + ["a", "a.txt", "text/plain", "abc"], + ["b", "b.txt", "text/plain", "ab"], + ]); }); - it("Replies with 415 error when FormData reaches the `files` limit (by default)", async () => { - const body = new FormData(); - body.append("file1", new File([Buffer.allocUnsafe(50)], "file1.txt")); - body.append("file2", new File([Buffer.allocUnsafe(150)], "file2.txt")); - - app.use(multipart({ limits: { files: 1 } })); + it("drops the parts over `parts` (`throw_limits: false`)", async (t) => { + const parsed: unknown[] = []; + const { url } = await serve(t, [ + new Multipart({ config: { limits: { parts: 2 } }, throw_limits: false }), + keep(parsed), + ]); + + const reply = await post( + url, + body( + part('name="file"; filename="file.txt"', "123"), + part('name="k1"', "v1"), + part('name="k2"', "v2"), + ), + ); - const response = await fetch(url, { method: "POST", body }); - await response.text(); - deepEqual(response.status, 415); + equal(reply.status, 200); + deepEqual(parsed, [ + ["file", "file.txt", "text/plain", "123"], + ["k1", "v1"], + ]); }); - it("Passes an incomplete FormData when it reaches the `files` limit (`throw_limits = true`)", async () => { - const body = new FormData(); - const file1 = new File([Buffer.allocUnsafe(50)], "file1.txt", { - type: "application/octet-stream", - }); - const file2 = new File([Buffer.allocUnsafe(150)], "file2.txt", { - type: "application/octet-stream", + it("`limit` (the size of the body, all parts included)", async (t) => { + const parsed: unknown[] = []; + const { url } = await serve(t, [ + new Multipart({ limit: 1024, throw_limits: false }), + keep(parsed), + ]); + // Keeps its connections, unless the server closes them + const agent = new Agent({ keepAlive: true }); + t.after(() => { + agent.destroy(); }); - body.append("file1", file1); - body.append("file2", file2); - - const assert = (context: Context): Promise => { - ok(context.request.body instanceof FormData); - - const actual_file1 = context.request.body.get("file1"); - const actual_file2 = context.request.body.get("file2"); - - ok(actual_file1 instanceof File); - ok(actual_file2 === null); - - deepEqual(actual_file1.name, file1.name); - deepEqual(actual_file1.size, file1.size); - deepEqual(actual_file1.type, file1.type); - - return context.send(); - }; - - app.use( - new Multipart({ config: { limits: { files: 1 } }, throw_limits: false }), - assert, + const headers = { "Content-Type": type }; + const small = body(part('name="field"', "value")); + const large = body( + part('name="field"', "value"), + part('name="file"; filename="file.bin"', "a".repeat(1024)), ); - const response = await fetch(url, { method: "POST", body }); - await response.text(); - deepEqual(response.status, 200); - }); - - it("Replies with 415 error when FormData reaches the `fileSize` limit (by default)", async () => { - const body = new FormData(); - body.append("file1", new File([Buffer.allocUnsafe(50)], "file1.txt")); - body.append("file2", new File([Buffer.allocUnsafe(150)], "file2.txt")); - - app.use(multipart({ limits: { fileSize: 100 } })); + const replies = [ + await send(url, { headers, body: small, agent }), + await send(url, { headers, body: large, agent }), + ].map(({ status, headers: { connection } }) => [status, connection]); - const response = await fetch(url, { method: "POST", body }); - await response.text(); - deepEqual(response.status, 415); + deepEqual(replies, [ + [200, "keep-alive"], + [413, "close"], + ]); + deepEqual(parsed, [["field", "value"]]); }); - it("Passes an incomplete FormData when it reaches the `fileSize` limit (`throw_limits = true`)", async () => { - const body = new FormData(); - const file1 = new File([Buffer.allocUnsafe(50)], "file1.txt", { - type: "application/octet-stream", - }); - const file2 = new File([Buffer.allocUnsafe(150)], "file2.txt", { - type: "application/octet-stream", + it("`limit` counts the bytes of a body without a `Content-Length`", async (t) => { + let reached = false; + const { url } = await serve(t, [ + new Multipart({ limit: 64 }), + (): void => { + reached = true; + }, + ]); + + // The parts that busboy reads after the limit are ignored + const reply = await send(url, { + headers: { "Content-Type": type, "Transfer-Encoding": "chunked" }, + body: body( + part('name="a"', "a".repeat(64)), + part('name="b"', "b"), + part('name="c"; filename="c.txt"', "c"), + ), }); - body.append("file1", file1); - body.append("file2", file2); - - const assert = (context: Context): Promise => { - ok(context.request.body instanceof FormData); - - const actual_file1 = context.request.body.get("file1"); - const actual_file2 = context.request.body.get("file2"); - - ok(actual_file1 instanceof File); - ok(actual_file2 instanceof File); - - deepEqual(actual_file1.name, file1.name); - deepEqual(actual_file1.size, file1.size); - deepEqual(actual_file1.type, file1.type); - - deepEqual(actual_file2.name, file2.name); - deepEqual(actual_file2.size, 100); - deepEqual(actual_file2.type, file2.type); - return context.send(); - }; - - app.use( - new Multipart({ - config: { limits: { fileSize: 100 } }, - throw_limits: false, - }), - assert, - ); - - const response = await fetch(url, { method: "POST", body }); - await response.text(); - deepEqual(response.status, 200); + deepEqual([reply.status, reply.headers.connection], [413, "close"]); + equal(reached, false); }); - it("Replies with 415 error when FormData reaches the `parts` limit (by default)", async () => { - const body = new FormData(); - const file = new File(["123"], "file.txt", { - type: "plain/txt", + it("`limit` refuses a larger `Content-Length` before reading the body", async (t) => { + let reached = false; + const { url } = await serve(t, [ + new Multipart({ limit: 1024 }), + (): void => { + reached = true; + }, + ]); + + // Only the headers are sent: the reply does not wait for the body + const reply = await new Promise((resolve, reject) => { + const client = httpRequest(url, { + method: "POST", + headers: { "Content-Type": type, "Content-Length": 2048 }, + signal: AbortSignal.timeout(5000), + }); + client + .once("response", (response) => { + response.resume(); + resolve(response.statusCode); + client.destroy(); + }) + .once("error", reject) + .flushHeaders(); }); - body.append("file", file); - body.append("k1", "v1"); - body.append("k2", "v2"); - app.use(multipart({ limits: { parts: 2 } })); + equal(reply, 413); + equal(reached, false); + }); - const response = await fetch(url, { method: "POST", body }); - await response.text(); - deepEqual(response.status, 415); + it("`limit` (invalid)", () => { + for (const limit of [-1, 0.5, Number.NaN]) { + throws( + () => new Multipart({ limit }), + new TypeError("`limit` is not a non-negative integer"), + ); + } }); - it("Passes an incomplete FormData when it reaches the `parts` limit (`throw_limits = true`)", async () => { - const body = new FormData(); - const file = new File(["123"], "file.txt", { - type: "plain/txt", + it("logs with the logger of the context, as `Multipart`", async (t) => { + const logger = new RecordingLogger(); + const { url } = await serve(t, [multipart({ limits: { fields: 0 } })], { + logger, }); - body.append("file", file); - body.append("k1", "v1"); - body.append("k2", "v2"); - const assert = (context: Context): Promise => { - ok(context.request.body instanceof FormData); + const reply = await post(url, body(part('name="a"', "1"))); - const actual_file = context.request.body.get("file"); - ok(actual_file instanceof File); - deepEqual(actual_file.name, file.name); - deepEqual(actual_file.size, file.size); - deepEqual(actual_file.type, file.type); - deepEqual(context.request.body.get("k1"), "v1"); - deepEqual(context.request.body.get("k2"), null); + equal(reply.status, 413); + const record = logger.records.find( + ({ message }) => message === "The multipart body has been refused", + ); + deepEqual( + [record?.level, record?.bindings.middleware, record?.extra], + ["debug", "Multipart", { status: 413 }], + ); + }); +}); - return context.send(); - }; +describe("Multipart (refusal)", () => { + it("replies to a client that is still sending its body", async (t) => { + let failure: unknown = null; + const parser = new Multipart({ config: { limits: { fileSize: 4 } } }); + const { url } = await serve(t, [ + async (context: Context): Promise => { + await parser.run(context).catch((error: unknown) => { + failure = error; + throw error; + }); + }, + ]); - app.use( - new Multipart({ config: { limits: { parts: 2 } }, throw_limits: false }), - assert, + // The body is never complete: the reply comes before it + const reply = await upload( + url, + part('name="file"; filename="file.bin"', "a".repeat(64)), ); - const response = await fetch(url, { method: "POST", body }); - await response.text(); - deepEqual(response.status, 200); + deepEqual(reply, [413, "close"]); + ok(failure instanceof BindenError); + equal(failure.status, 413); }); - afterEach(async () => { - server.closeIdleConnections(); - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - } else { - resolve(); - } + it("destroys the request once its response has started", async () => { + const outcomes: unknown[] = []; + const parser = new Multipart({ limit: 16 }); + const app = new Binden().use(async (context: Context) => { + context.response.flushHeaders(); + await parser.run(context).catch((error: unknown) => { + outcomes.push((error as BindenError).status, context.request.destroyed); }); }); + const test = await listen(app); + + await post(test.url, body(part('name="a"', "a".repeat(64)))).catch( + (error: unknown) => { + outcomes.push((error as NodeJS.ErrnoException).code); + }, + ); + await test.close(); + + deepEqual(outcomes, [413, true, "ECONNRESET"]); }); }); From f2a13a3840b44d4f656e0fec1b2e130b56f15509 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 01:38:54 +0300 Subject: [PATCH 158/675] feat!: check the options and take them in the factory of multipart The options were not checked: a misspelled limit (`fileSizes`), a limit given at the wrong level, or a charset that busboy cannot decode was silently ignored, and the request was parsed without the protection the application meant to set. The options are now checked in the constructor: an unknown option (also in `config` and `config.limits`) or a value of another type is a `TypeError`, and a size that is not a non-negative integer (or `Infinity`, which also disables `limit`), or a charset that busboy does not decode, a `RangeError`. A `headers` key of `config` is still ignored: busboy reads the headers of the request. The default export took the configuration of busboy, unlike the class: it now takes the options of `Multipart`, so that `throw_limits`, `limit` and the others can be set with it too. Its old use, `multipart({ limits })`, fails the type check and throws, instead of dropping the limits. The parameters of the parts (e.g. the file names) are decoded as UTF-8 by default (`config.defParamCharset`): busboy decodes them as latin1, which garbled the names of the browsers. BREAKING CHANGE: the default export takes the options of `Multipart`: `multipart(config)` becomes `multipart({ config })`. An unknown option, or an invalid value, throws when the middleware is created. The parameters of the parts are decoded as UTF-8 unless `config.defParamCharset` is set. Co-Authored-By: Claude Opus 5.5 --- packages/multipart/README.md | 4 +- packages/multipart/index.ts | 10 +- packages/multipart/src/multipart.ts | 68 ++----- packages/multipart/src/options.ts | 218 ++++++++++++++++++++++ packages/multipart/test/multipart.test.ts | 23 +-- packages/multipart/test/options.test.ts | 192 +++++++++++++++++++ 6 files changed, 442 insertions(+), 73 deletions(-) create mode 100644 packages/multipart/src/options.ts create mode 100644 packages/multipart/test/options.test.ts diff --git a/packages/multipart/README.md b/packages/multipart/README.md index 51928776..4fbd6594 100644 --- a/packages/multipart/README.md +++ b/packages/multipart/README.md @@ -14,7 +14,9 @@ npm install @binden/multipart import { type Context, Router } from "binden"; import multipart from "@binden/multipart"; -const formDataParser = multipart({ limits: { fields: 2, fileSize: 1024 } }); +const formDataParser = multipart({ + config: { limits: { fields: 2, fileSize: 1024 } }, +}); const next = (context: Context): Promise => { if (context.request.body instanceof FormData) { diff --git a/packages/multipart/index.ts b/packages/multipart/index.ts index cac77d56..dd807c7d 100644 --- a/packages/multipart/index.ts +++ b/packages/multipart/index.ts @@ -1,7 +1,9 @@ -import { type IMultipartConfig, Multipart } from "./src/multipart.js"; +import type { IMultipartOptions } from "./src/options.js"; +import { Multipart } from "./src/multipart.js"; export * from "./src/multipart.js"; +export type { IMultipartConfig, IMultipartOptions } from "./src/options.js"; -/** A `Multipart` with the configuration of busboy */ -export default (config: IMultipartConfig = {}): Multipart => - new Multipart({ config }); +/** A `Multipart` of the options (see `IMultipartOptions`) */ +export default (options?: IMultipartOptions): Multipart => + new Multipart(options); diff --git a/packages/multipart/src/multipart.ts b/packages/multipart/src/multipart.ts index 2ee20dcf..99200868 100644 --- a/packages/multipart/src/multipart.ts +++ b/packages/multipart/src/multipart.ts @@ -1,9 +1,10 @@ +import { BindenError, type Context, Middleware } from "binden"; import { - BindenError, - type Context, - type IMiddlewareParams, - Middleware, -} from "binden"; + type IMultipartConfig, + type IMultipartOptions, + type IParserOptions, + validateOptions, +} from "./options.js"; import busboy, { type Busboy, type BusboyConfig, @@ -12,26 +13,6 @@ import busboy, { } from "busboy"; import type { Readable } from "node:stream"; -/** The configuration of busboy (its `headers` are those of the request) */ -export type IMultipartConfig = Omit; - -/** The options of `Multipart` */ -export interface IMultipartOptions extends IMiddlewareParams { - /** The configuration of busboy, e.g. its `limits` */ - config?: IMultipartConfig | undefined; - /** - * Whether a request that reaches a limit of `config.limits` is refused - * with `413 Content Too Large` (`true`), instead of truncated - */ - throw_limits?: boolean | undefined; - /** - * The largest body in bytes, all of its parts included (no limit by - * default): a larger one is not read further, and gets a - * `413 Content Too Large`, whatever `throw_limits` - */ - limit?: number | undefined; -} - /** The logger of a context */ type ILog = Context["log"]; @@ -107,11 +88,7 @@ class Parser { public constructor( context: Context, log: ILog, - { - config, - throw_limits, - limit, - }: { config: IMultipartConfig; throw_limits: boolean; limit: number }, + { config, throw_limits, limit }: IParserOptions, ) { this.#context = context; this.#log = log; @@ -347,26 +324,13 @@ class Parser { * request of another media type is skipped (its body is not read) */ export class Multipart extends Middleware { - readonly #config: IMultipartConfig; - readonly #throw_limits: boolean; - readonly #limit: number; + readonly #options: IParserOptions; - public constructor({ - config = {}, - throw_limits = true, - limit, - ...rest - }: IMultipartOptions = {}) { - super(rest); - if ( - typeof limit !== "undefined" && - !(Number.isSafeInteger(limit) && limit >= 0) - ) { - throw new TypeError("`limit` is not a non-negative integer"); - } - this.#config = config; - this.#throw_limits = throw_limits; - this.#limit = limit ?? Number.POSITIVE_INFINITY; + /** Checks the options: a `TypeError` or a `RangeError` for an invalid one */ + public constructor(options: IMultipartOptions = {}) { + const { middleware, parser } = validateOptions(options); + super(middleware); + this.#options = parser; } /** @@ -391,10 +355,6 @@ export class Multipart extends Middleware { } parsed.add(request); - await new Parser(context, log, { - config: this.#config, - throw_limits: this.#throw_limits, - limit: this.#limit, - }).run(); + await new Parser(context, log, this.#options).run(); } } diff --git a/packages/multipart/src/options.ts b/packages/multipart/src/options.ts new file mode 100644 index 00000000..3aa9d7be --- /dev/null +++ b/packages/multipart/src/options.ts @@ -0,0 +1,218 @@ +import type { BusboyConfig, Limits } from "busboy"; +import type { IMiddlewareParams } from "binden"; + +/** + * The configuration of busboy: its `limits`, the charsets of the parts and + * the sizes of its buffers (its `headers` are those of the request: a + * `headers` key is ignored) + */ +export type IMultipartConfig = Omit; + +/** The options of `Multipart` */ +export interface IMultipartOptions extends IMiddlewareParams { + /** The configuration of busboy, e.g. its `limits` (none by default but `fieldSize`, 1 MiB) */ + config?: IMultipartConfig | undefined; + /** + * Whether a request that reaches a limit of `config.limits` is refused + * with `413 Content Too Large` (`true`), instead of truncated + */ + throw_limits?: boolean | undefined; + /** + * The largest body in bytes, all of its parts included (no limit by + * default, as with `Infinity`): a larger one is not read further, and + * gets a `413 Content Too Large`, whatever `throw_limits` + */ + limit?: number | undefined; +} + +/** The options of a parse, checked, with their defaults */ +export interface IParserOptions { + readonly config: IMultipartConfig; + readonly throw_limits: boolean; + readonly limit: number; +} + +/** The options of `Multipart` that its parent class takes */ +type IMiddlewareOptions = Pick; + +const optionNames: ReadonlySet = new Set([ + "disabled", + "ignore_errors", + "config", + "throw_limits", + "limit", +]); + +const configNames: ReadonlySet = new Set([ + "headers", + "limits", + "highWaterMark", + "fileHwm", + "defCharset", + "defParamCharset", + "preservePath", +]); + +const limitNames: ReadonlySet = new Set([ + "fieldNameSize", + "fieldSize", + "fields", + "fileSize", + "files", + "parts", + "headerPairs", +]); + +/** The charsets that busboy decodes (in lowercase) */ +const charsets: ReadonlySet = new Set([ + "utf-8", + "utf8", + "latin1", + "ascii", + "us-ascii", + "iso-8859-1", + "iso8859-1", + "iso88591", + "iso_8859-1", + "windows-1252", + "iso_8859-1:1987", + "cp1252", + "x-cp1252", + "utf16le", + "utf-16le", + "ucs2", + "ucs-2", + "base64", +]); + +/** Whether a value is an object (not an array) */ +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** An object of known keys (a `TypeError` otherwise) */ +function checkObject( + value: unknown, + name: string, + keys: ReadonlySet, +): Record { + if (!isObject(value)) { + throw new TypeError(`\`${name}\` is not an object`); + } + for (const key of Object.keys(value)) { + if (!keys.has(key)) { + throw new TypeError(`Unknown option: \`${name}.${key}\``); + } + } + return value; +} + +/** A size: a non-negative integer, or `Infinity` */ +function checkSize(value: unknown, name: string): number { + if (typeof value !== "number") { + throw new TypeError(`\`${name}\` is not a number`); + } else if ( + value !== Number.POSITIVE_INFINITY && + !(Number.isSafeInteger(value) && value >= 0) + ) { + throw new RangeError(`\`${name}\` is not a non-negative integer`); + } + return value; +} + +/** The size of a buffer: a non-negative integer */ +function checkBuffer(value: unknown, name: string): number { + if (typeof value !== "number") { + throw new TypeError(`\`${name}\` is not a number`); + } else if (!(Number.isSafeInteger(value) && value >= 0)) { + throw new RangeError(`\`${name}\` is not a non-negative integer`); + } + return value; +} + +/** A charset that busboy decodes */ +function checkCharset(value: unknown, name: string): string { + if (typeof value !== "string") { + throw new TypeError(`\`${name}\` is not a string`); + } else if (!charsets.has(value.toLowerCase())) { + throw new RangeError(`\`${name}\` is not a charset that busboy decodes`); + } + return value; +} + +/** The limits of busboy */ +function checkLimits(value: unknown): Limits { + const limits = checkObject(value, "config.limits", limitNames); + return Object.fromEntries( + Object.entries(limits) + .filter(([, size]) => typeof size !== "undefined") + .map(([name, size]) => [name, checkSize(size, `config.limits.${name}`)]), + ); +} + +/** + * The configuration of busboy, without `headers`: the parameters of the + * parts (e.g. their file names) are decoded as UTF-8 by default + */ +function checkConfig(value: unknown): IMultipartConfig { + const { + limits, + highWaterMark, + fileHwm, + defCharset, + defParamCharset = "utf8", + preservePath, + } = checkObject(value, "config", configNames); + if ( + typeof preservePath !== "undefined" && + typeof preservePath !== "boolean" + ) { + throw new TypeError("`config.preservePath` is not a boolean"); + } + return { + defParamCharset: checkCharset(defParamCharset, "config.defParamCharset"), + ...(typeof limits === "undefined" ? {} : { limits: checkLimits(limits) }), + ...(typeof highWaterMark === "undefined" + ? {} + : { highWaterMark: checkBuffer(highWaterMark, "config.highWaterMark") }), + ...(typeof fileHwm === "undefined" + ? {} + : { fileHwm: checkBuffer(fileHwm, "config.fileHwm") }), + ...(typeof defCharset === "undefined" + ? {} + : { defCharset: checkCharset(defCharset, "config.defCharset") }), + ...(typeof preservePath === "undefined" ? {} : { preservePath }), + }; +} + +/** + * The options of `Multipart`, checked (a `TypeError` for an unknown option + * or a value of another type, a `RangeError` for a value out of its range), + * with their defaults + */ +export function validateOptions(input: unknown): { + middleware: IMiddlewareOptions; + parser: IParserOptions; +} { + const { + disabled, + ignore_errors, + config = {}, + throw_limits = true, + limit = Number.POSITIVE_INFINITY, + } = checkObject(input, "options", optionNames); + if (typeof throw_limits !== "boolean") { + throw new TypeError("`throw_limits` is not a boolean"); + } + return { + middleware: { + disabled: disabled as boolean | undefined, + ignore_errors: ignore_errors as boolean | undefined, + }, + parser: { + config: checkConfig(config), + throw_limits, + limit: checkSize(limit, "limit"), + }, + }; +} diff --git a/packages/multipart/test/multipart.test.ts b/packages/multipart/test/multipart.test.ts index 7e24d168..4965b9a6 100644 --- a/packages/multipart/test/multipart.test.ts +++ b/packages/multipart/test/multipart.test.ts @@ -12,7 +12,7 @@ import { type, upload, } from "./helpers.js"; -import { deepEqual, equal, ok, throws } from "node:assert/strict"; +import { deepEqual, equal, ok } from "node:assert/strict"; import { describe, it } from "node:test"; import multipart, { Multipart } from "../index.js"; @@ -269,7 +269,7 @@ describe("Multipart", () => { it(`replies \`413\` once the \`${name}\` limit is reached`, async (t) => { let reached = false; const { url } = await serve(t, [ - multipart({ limits: config }), + multipart({ config: { limits: config } }), (): void => { reached = true; }, @@ -464,20 +464,15 @@ describe("Multipart", () => { equal(reached, false); }); - it("`limit` (invalid)", () => { - for (const limit of [-1, 0.5, Number.NaN]) { - throws( - () => new Multipart({ limit }), - new TypeError("`limit` is not a non-negative integer"), - ); - } - }); - it("logs with the logger of the context, as `Multipart`", async (t) => { const logger = new RecordingLogger(); - const { url } = await serve(t, [multipart({ limits: { fields: 0 } })], { - logger, - }); + const { url } = await serve( + t, + [multipart({ config: { limits: { fields: 0 } } })], + { + logger, + }, + ); const reply = await post(url, body(part('name="a"', "1"))); diff --git a/packages/multipart/test/options.test.ts b/packages/multipart/test/options.test.ts new file mode 100644 index 00000000..d3c41241 --- /dev/null +++ b/packages/multipart/test/options.test.ts @@ -0,0 +1,192 @@ +import { body, part, post, serve } from "./helpers.js"; +import { deepEqual, equal, throws } from "node:assert/strict"; +import { describe, it } from "node:test"; +import multipart, { type IMultipartOptions, Multipart } from "../index.js"; +import type { Context } from "binden"; + +/** A middleware that keeps the fields and the names of the files */ +function keep(parsed: unknown[]): (context: Context) => Promise { + return async (context: Context): Promise => { + for (const [name, value] of context.request.body as FormData) { + parsed.push([name, typeof value === "string" ? value : value.name]); + } + await context.send(); + }; +} + +describe("Multipart (options)", () => { + it("refuses invalid options", () => { + const invalid: [unknown, Error][] = [ + [null, new TypeError("`options` is not an object")], + [[], new TypeError("`options` is not an object")], + [{ limits: {} }, new TypeError("Unknown option: `options.limits`")], + [{ disabled: 1 }, new TypeError("`disabled` is not a boolean")], + [ + { throw_limits: "no" }, + new TypeError("`throw_limits` is not a boolean"), + ], + [{ limit: "1" }, new TypeError("`limit` is not a number")], + ...[-1, 0.5, Number.NaN, Number.NEGATIVE_INFINITY].map( + (limit): [unknown, Error] => [ + { limit }, + new RangeError("`limit` is not a non-negative integer"), + ], + ), + [{ config: null }, new TypeError("`config` is not an object")], + [ + { config: { limit: 1 } }, + new TypeError("Unknown option: `config.limit`"), + ], + [ + { config: { limits: 1 } }, + new TypeError("`config.limits` is not an object"), + ], + [ + { config: { limits: { fileSizes: 1 } } }, + new TypeError("Unknown option: `config.limits.fileSizes`"), + ], + [ + { config: { limits: { fileSize: "1" } } }, + new TypeError("`config.limits.fileSize` is not a number"), + ], + [ + { config: { limits: { parts: -1 } } }, + new RangeError("`config.limits.parts` is not a non-negative integer"), + ], + [ + { config: { highWaterMark: Number.POSITIVE_INFINITY } }, + new RangeError("`config.highWaterMark` is not a non-negative integer"), + ], + [ + { config: { fileHwm: "1" } }, + new TypeError("`config.fileHwm` is not a number"), + ], + [ + { config: { defCharset: "koi8-r" } }, + new RangeError( + "`config.defCharset` is not a charset that busboy decodes", + ), + ], + [ + { config: { defParamCharset: 8 } }, + new TypeError("`config.defParamCharset` is not a string"), + ], + [ + { config: { preservePath: 1 } }, + new TypeError("`config.preservePath` is not a boolean"), + ], + ]; + for (const [options, error] of invalid) { + throws(() => new Multipart(options as IMultipartOptions), error); + } + }); + + it("accepts valid options", () => { + const parser = new Multipart({ + disabled: true, + ignore_errors: true, + throw_limits: false, + limit: Number.POSITIVE_INFINITY, + config: { + limits: { + fieldNameSize: 100, + fieldSize: 0, + fields: Number.POSITIVE_INFINITY, + // Unset + fileSize: new Map().get("fileSize"), + files: 1, + parts: 2, + headerPairs: 2000, + }, + highWaterMark: 0, + fileHwm: 1024, + defCharset: "UTF-8", + defParamCharset: "latin1", + preservePath: false, + }, + }); + deepEqual([parser.disabled, parser.ignore_errors], [true, true]); + equal(multipart().disabled, false); + }); + + it("is created by the default export of the options", async (t) => { + const parsed: unknown[] = []; + const { url } = await serve(t, [ + multipart({ throw_limits: false, config: { limits: { fields: 1 } } }), + keep(parsed), + ]); + + const reply = await post( + url, + body(part('name="a"', "1"), part('name="b"', "2")), + ); + + equal(reply.status, 200); + deepEqual(parsed, [["a", "1"]]); + }); + + it("ignores the `headers` of `config`", async (t) => { + const parsed: unknown[] = []; + const headers = { "content-type": "text/plain" }; + const config = { headers } as IMultipartOptions["config"]; + const { url } = await serve(t, [multipart({ config }), keep(parsed)]); + + const reply = await post(url, body(part('name="a"', "1"))); + + equal(reply.status, 200); + deepEqual(parsed, [["a", "1"]]); + }); + + it("decodes the parameters of the parts as UTF-8 by default", async (t) => { + const parsed: unknown[] = []; + const latin1: unknown[] = []; + const { url } = await serve(t, [multipart(), keep(parsed)]); + const other = await serve(t, [ + multipart({ config: { defParamCharset: "latin1" } }), + keep(latin1), + ]); + const data = Buffer.from( + body( + part('name="поле"', "значение"), + part('name="файл"; filename="файл.txt"', "…"), + ), + ); + + deepEqual( + [(await post(url, data)).status, (await post(other.url, data)).status], + [200, 200], + ); + deepEqual(parsed, [ + ["поле", "значение"], + ["файл", "файл.txt"], + ]); + deepEqual(latin1, [ + [Buffer.from("поле").toString("latin1"), "значение"], + [ + Buffer.from("файл").toString("latin1"), + Buffer.from("файл.txt").toString("latin1"), + ], + ]); + }); + + it("passes the charsets and `preservePath` to busboy", async (t) => { + const parsed: unknown[] = []; + const { url } = await serve(t, [ + multipart({ config: { defCharset: "latin1", preservePath: true } }), + keep(parsed), + ]); + + const reply = await post( + url, + Buffer.from( + body(part('name="a"', "é"), part('name="b"; filename="d/b.txt"', "")), + ), + ); + + equal(reply.status, 200); + deepEqual(parsed, [ + ["a", Buffer.from("é").toString("latin1")], + ["b", "d/b.txt"], + ]); + }); +}); From 4378f7c2d67524b032828e697c76f7953c123584 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 01:41:28 +0300 Subject: [PATCH 159/675] feat: tell the truncated fields and files in multipart With `throw_limits: false` busboy truncates a field to `fieldSize` and a file to `fileSize`, and drops the parts over `fields`, `files` and `parts`, but the application could not tell: a truncated form looked complete. `multipartInfo(context)` returns the names of the truncated fields and the count limits that have been reached (`null` for a request that `Multipart` has not parsed, e.g. another media type). It also tells the limit that has refused a request, e.g. to the error handler. The files of the memory storage are a `MultipartFile`: a `File` with `truncated`. Co-Authored-By: Claude Opus 5.5 --- packages/multipart/index.ts | 6 ++ packages/multipart/src/file.ts | 29 ++++++ packages/multipart/src/info.ts | 50 +++++++++++ packages/multipart/src/multipart.ts | 49 +++++++---- packages/multipart/test/info.test.ts | 127 +++++++++++++++++++++++++++ 5 files changed, 244 insertions(+), 17 deletions(-) create mode 100644 packages/multipart/src/file.ts create mode 100644 packages/multipart/src/info.ts create mode 100644 packages/multipart/test/info.test.ts diff --git a/packages/multipart/index.ts b/packages/multipart/index.ts index dd807c7d..d21514fe 100644 --- a/packages/multipart/index.ts +++ b/packages/multipart/index.ts @@ -2,6 +2,12 @@ import type { IMultipartOptions } from "./src/options.js"; import { Multipart } from "./src/multipart.js"; export * from "./src/multipart.js"; +export * from "./src/file.js"; +export { + type IMultipartInfo, + type ILimitName, + multipartInfo, +} from "./src/info.js"; export type { IMultipartConfig, IMultipartOptions } from "./src/options.js"; /** A `Multipart` of the options (see `IMultipartOptions`) */ diff --git a/packages/multipart/src/file.ts b/packages/multipart/src/file.ts new file mode 100644 index 00000000..46ed1b11 --- /dev/null +++ b/packages/multipart/src/file.ts @@ -0,0 +1,29 @@ +/** The options of a `MultipartFile`: those of a `File`, and whether it is truncated */ +export interface IMultipartFileOptions extends NonNullable< + ConstructorParameters[2] +> { + /** Whether the content has been truncated to `fileSize` (`false`) */ + truncated?: boolean | undefined; +} + +/** + * A file of the memory storage: a `File` that tells whether its content has + * been truncated (`fileSize`, with `throw_limits: false`) + */ +export class MultipartFile extends File { + readonly #truncated: boolean; + + public constructor( + bits: ConstructorParameters[0], + name: string, + { truncated = false, ...options }: IMultipartFileOptions = {}, + ) { + super(bits, name, options); + this.#truncated = truncated; + } + + /** Whether the content has been truncated to `fileSize` */ + public get truncated(): boolean { + return this.#truncated; + } +} diff --git a/packages/multipart/src/info.ts b/packages/multipart/src/info.ts new file mode 100644 index 00000000..e3d7833e --- /dev/null +++ b/packages/multipart/src/info.ts @@ -0,0 +1,50 @@ +import type { Context } from "binden"; + +/** A limit of busboy on the count of the parts */ +export type ILimitName = "fields" | "files" | "parts"; + +/** + * What a parse has truncated or dropped (with `throw_limits: false`; with + * `true` the request is refused instead) + */ +export interface IMultipartInfo { + /** The names of the fields truncated to `fieldSize`, in their order */ + readonly truncated_fields: readonly string[]; + /** The limits on the count of the parts that have been reached: the parts over them are dropped */ + readonly limits_hit: readonly ILimitName[]; +} + +/** What a parse records while it reads the body */ +export interface IParseInfo { + readonly truncated_fields: string[]; + readonly limits_hit: ILimitName[]; +} + +/** The requests whose body has been parsed (or is being parsed) */ +const infos = new WeakMap(); + +/** Records a parse of the request: `null` if it has already been parsed */ +export function startParse(request: object): IParseInfo | null { + if (infos.has(request)) { + return null; + } + const info: IParseInfo = { truncated_fields: [], limits_hit: [] }; + infos.set(request, info); + return info; +} + +/** + * What the parse of the multipart body of the request has truncated or + * dropped, e.g. to tell the client: `null` when `Multipart` has not parsed it + * (e.g. another media type) + */ +export function multipartInfo(context: Context): IMultipartInfo | null { + const info = infos.get(context.request); + if (typeof info === "undefined") { + return null; + } + return Object.freeze({ + truncated_fields: Object.freeze([...info.truncated_fields]), + limits_hit: Object.freeze([...info.limits_hit]), + }); +} diff --git a/packages/multipart/src/multipart.ts b/packages/multipart/src/multipart.ts index 99200868..5b573263 100644 --- a/packages/multipart/src/multipart.ts +++ b/packages/multipart/src/multipart.ts @@ -1,4 +1,5 @@ import { BindenError, type Context, Middleware } from "binden"; +import { type ILimitName, type IParseInfo, startParse } from "./info.js"; import { type IMultipartConfig, type IMultipartOptions, @@ -11,6 +12,7 @@ import busboy, { type FieldInfo, type FileInfo, } from "busboy"; +import { MultipartFile } from "./file.js"; import type { Readable } from "node:stream"; /** The logger of a context */ @@ -22,7 +24,7 @@ type IFileStream = Readable & { truncated?: boolean }; /** A part, in the order of the request (a file once it has been read) */ interface ISlot { readonly name: string; - value: File | string | null; + value: MultipartFile | string | null; } /** The methods whose requests have no body to parse */ @@ -36,9 +38,6 @@ const methods: ReadonlySet = new Set([ /** A boundary (RFC 2046, section 5.1.1): 1 to 70 characters, without a trailing space */ const boundaries = /^[\w'()+,\-./:=? ]{0,69}[\w'()+,\-./:=?]$/u; -/** The requests that have been parsed, or are being parsed */ -const parsed = new WeakSet(); - /** Observes an error that is reported otherwise */ function ignore(): void { // Observed @@ -74,6 +73,7 @@ async function collect(stream: Readable): Promise[]> { class Parser { readonly #context: Context; readonly #log: ILog; + readonly #info: IParseInfo; readonly #config: IMultipartConfig; readonly #throw_limits: boolean; readonly #limit: number; @@ -88,10 +88,12 @@ class Parser { public constructor( context: Context, log: ILog, + info: IParseInfo, { config, throw_limits, limit }: IParserOptions, ) { this.#context = context; this.#log = log; + this.#info = info; this.#config = config; this.#throw_limits = throw_limits; this.#limit = limit; @@ -146,13 +148,13 @@ class Parser { this.#onFile(name, stream, info); }) .once("fieldsLimit", () => { - this.#limitReached("fields"); + this.#countReached("fields"); }) .once("filesLimit", () => { - this.#limitReached("files"); + this.#countReached("files"); }) .once("partsLimit", () => { - this.#limitReached("parts"); + this.#countReached("parts"); }) .once("close", () => { Promise.all(this.#tasks).then(() => { @@ -184,6 +186,8 @@ class Parser { value: string | undefined, { valueTruncated }: FieldInfo, ): void { + // A part without a name (or with an empty one) is named `""` + const field = name ?? ""; if (this.#over) { return; } else if (typeof value === "undefined") { @@ -191,12 +195,14 @@ class Parser { const message = "The charset of a field is not supported"; this.#refuse(new BindenError(415, { message })); return; - } else if (valueTruncated && this.#throw_limits) { - this.#limitReached("fieldSize"); - return; + } else if (valueTruncated) { + this.#info.truncated_fields.push(field); + if (this.#throw_limits) { + this.#limitReached("fieldSize"); + return; + } } - // A part without a name (or with an empty one) is named `""` - this.#slots.push({ name: name ?? "", value }); + this.#slots.push({ name: field, value }); } #onFile( @@ -222,7 +228,10 @@ class Parser { this.#tasks.push( collect(stream).then( (chunks) => { - slot.value = new File(chunks, String(filename), { type: mimeType }); + slot.value = new MultipartFile(chunks, String(filename), { + type: mimeType, + truncated: stream.truncated === true, + }); }, // The parser reports why the stream has failed ignore, @@ -230,6 +239,12 @@ class Parser { ); } + /** A limit on the count of the parts: the parts over it are dropped */ + #countReached(limit: ILimitName): void { + this.#info.limits_hit.push(limit); + this.#limitReached(limit); + } + /** A limit of busboy (refused with `throw_limits`, otherwise truncated) */ #limitReached(limit: string): void { if (this.#throw_limits) { @@ -349,12 +364,12 @@ export class Multipart extends Middleware { } else if (type !== "multipart/form-data") { log.debug("The body is not `multipart/form-data`", { type }); return; - } else if (parsed.has(request)) { + } + const info = startParse(request); + if (info === null) { log.debug("The body has already been parsed"); return; } - parsed.add(request); - - await new Parser(context, log, this.#options).run(); + await new Parser(context, log, info, this.#options).run(); } } diff --git a/packages/multipart/test/info.test.ts b/packages/multipart/test/info.test.ts new file mode 100644 index 00000000..46dec4ae --- /dev/null +++ b/packages/multipart/test/info.test.ts @@ -0,0 +1,127 @@ +import { body, part, post, send, serve } from "./helpers.js"; +import { deepEqual, equal, ok } from "node:assert/strict"; +import { describe, it } from "node:test"; +import multipart, { MultipartFile, multipartInfo } from "../index.js"; +import type { Context } from "binden"; + +describe("multipartInfo()", () => { + it("tells the truncated fields and the reached limits (`throw_limits: false`)", async (t) => { + const seen: unknown[] = []; + const { url } = await serve(t, [ + (context: Context): void => { + seen.push(multipartInfo(context)); + }, + multipart({ + throw_limits: false, + config: { limits: { fieldSize: 3, fields: 2, files: 1, parts: 5 } }, + }), + async (context: Context): Promise => { + const info = multipartInfo(context); + ok(info !== null); + ok(Object.isFrozen(info)); + ok(Object.isFrozen(info.truncated_fields)); + ok(Object.isFrozen(info.limits_hit)); + const form = context.request.body as FormData; + seen.push(info, [...form.keys()]); + await context.send(); + }, + ]); + + const reply = await post( + url, + body( + part('name="a"', "abcdef"), + part('name="b"', "xy"), + part('name="c"', "dropped (`fields`)"), + part('name="f1"; filename="f1.txt"', "1"), + part('name="f2"; filename="f2.txt"', "dropped (`files`)"), + part('name="d"', "dropped (`parts`)"), + ), + ); + + equal(reply.status, 200); + deepEqual(seen, [ + null, + { truncated_fields: ["a"], limits_hit: ["fields", "files", "parts"] }, + ["a", "b", "f1"], + ]); + }); + + it("tells the limit that has refused a request", async (t) => { + const seen: unknown[] = []; + const { url } = await serve( + t, + [multipart({ config: { limits: { files: 0 } } })], + { + error_handler: async (context: Context): Promise => { + seen.push(multipartInfo(context)); + await context.status(413).send(); + }, + }, + ); + + const reply = await post(url, body(part('name="f"; filename="f"', "1"))); + + equal(reply.status, 413); + deepEqual(seen, [{ truncated_fields: [], limits_hit: ["files"] }]); + }); + + it("is `null` for a request that `Multipart` has not parsed", async (t) => { + const seen: unknown[] = []; + const { url } = await serve(t, [ + multipart(), + async (context: Context): Promise => { + seen.push(multipartInfo(context)); + await context.send(); + }, + ]); + + await send(url, { method: "GET" }); + await send(url, { headers: { "Content-Type": "text/plain" }, body: "a" }); + await post(url, body(part('name="a"', "1"))); + + deepEqual(seen, [null, null, { truncated_fields: [], limits_hit: [] }]); + }); +}); + +describe("MultipartFile", () => { + it("is a `File` that tells whether it has been truncated", async () => { + const file = new MultipartFile(["abc"], "a.txt", { type: "text/plain" }); + const truncated = new MultipartFile(["ab"], "b.txt", { truncated: true }); + + ok(file instanceof File); + deepEqual( + [file.name, file.type, await file.text(), file.truncated], + ["a.txt", "text/plain", "abc", false], + ); + deepEqual([truncated.name, truncated.truncated], ["b.txt", true]); + }); + + it("is the file of the memory storage", async (t) => { + const files: unknown[] = []; + const { url } = await serve(t, [ + multipart({ throw_limits: false, config: { limits: { fileSize: 2 } } }), + async (context: Context): Promise => { + for (const value of (context.request.body as FormData).values()) { + ok(value instanceof MultipartFile); + files.push([value.name, await value.text(), value.truncated]); + } + await context.send(); + }, + ]); + + const reply = await post( + url, + body( + part('name="a"; filename="a.txt"', "1"), + part('name="b"; filename="b.txt"', "123"), + ), + ); + + equal(reply.status, 200); + deepEqual(files, [ + ["a.txt", "1", false], + ["b.txt", "12", true], + ]); + }); +}); From 418e53dc8c2954b9047fea6d54ee382b107ee48c Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 01:44:38 +0300 Subject: [PATCH 160/675] fix: clean the names of the files in multipart The name of a file is chosen by the client: it reached the application as sent, with control characters (e.g. a newline in a log line or a header), the characters that control the direction of the text (U+202E shows `invoicetxt.exe` as `invoiceexe.txt`), any length, and as "undefined" for a file without a name (a part that is a file by its type only). The name of a file now has no control characters (C0, DEL, C1) nor direction controls, is cut to 255 bytes of UTF-8 (the limit of most file systems) between two characters, and is `"blob"` when it is empty, as in a `FormData`. It remains metadata: it is never a path to write to. Co-Authored-By: Claude Opus 5.5 --- packages/multipart/src/filename.ts | 27 ++++++++++++ packages/multipart/src/multipart.ts | 3 +- packages/multipart/test/filename.test.ts | 56 ++++++++++++++++++++++++ packages/multipart/test/options.test.ts | 5 ++- 4 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 packages/multipart/src/filename.ts create mode 100644 packages/multipart/test/filename.test.ts diff --git a/packages/multipart/src/filename.ts b/packages/multipart/src/filename.ts new file mode 100644 index 00000000..eeeb632b --- /dev/null +++ b/packages/multipart/src/filename.ts @@ -0,0 +1,27 @@ +/** + * The control characters (C0, DEL and C1) and those of the direction of the + * text, which can disguise a name (e.g. `invoice\u202Etxt.exe`) + */ +const controls = /[\p{Cc}\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu; + +/** The longest name of a file, in bytes of UTF-8 (that of most file systems) */ +const MAX_BYTES = 255; + +/** + * The name of a file as the client has sent it, to keep as metadata (never + * as a path): without control characters, cut to 255 bytes of UTF-8 between + * two characters, and `"blob"` when it is empty (or missing) + */ +export function filenameOf(input: string | undefined): string { + const name = (input ?? "").replace(controls, ""); + let size = 0; + let end = 0; + for (const char of name) { + size += Buffer.byteLength(char); + if (size > MAX_BYTES) { + break; + } + end += char.length; + } + return end === 0 ? "blob" : name.slice(0, end); +} diff --git a/packages/multipart/src/multipart.ts b/packages/multipart/src/multipart.ts index 5b573263..14a0de96 100644 --- a/packages/multipart/src/multipart.ts +++ b/packages/multipart/src/multipart.ts @@ -14,6 +14,7 @@ import busboy, { } from "busboy"; import { MultipartFile } from "./file.js"; import type { Readable } from "node:stream"; +import { filenameOf } from "./filename.js"; /** The logger of a context */ type ILog = Context["log"]; @@ -228,7 +229,7 @@ class Parser { this.#tasks.push( collect(stream).then( (chunks) => { - slot.value = new MultipartFile(chunks, String(filename), { + slot.value = new MultipartFile(chunks, filenameOf(filename), { type: mimeType, truncated: stream.truncated === true, }); diff --git a/packages/multipart/test/filename.test.ts b/packages/multipart/test/filename.test.ts new file mode 100644 index 00000000..6df54523 --- /dev/null +++ b/packages/multipart/test/filename.test.ts @@ -0,0 +1,56 @@ +import { body, part, post, serve } from "./helpers.js"; +import { deepEqual, equal } from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { Context } from "binden"; +import multipart from "../index.js"; + +/** A part of a file of the name, encoded as UTF-8 (RFC 8187) */ +function file(name: string): string { + return part(`name="f"; filename*=utf-8''${encodeURIComponent(name)}`, ""); +} + +describe("Multipart (file names)", () => { + it("removes the control characters, keeps 255 bytes, and names an unnamed file `blob`", async (t) => { + const names: string[] = []; + const { url } = await serve(t, [ + multipart(), + async (context: Context): Promise => { + for (const value of (context.request.body as FormData).values()) { + names.push((value as File).name); + } + await context.send(); + }, + ]); + const emoji = "😀"; + + const reply = await post( + url, + body( + file("a\u0000b\u0001c\td\u007fe\u0085f.txt"), + file("invoice\u202Etxt.exe\u2066\u2069\u200E\u200F\u061C"), + file("a".repeat(300)), + file("я".repeat(200)), + file(`${"a".repeat(253)}${emoji}`), + file(`${"a".repeat(251)}${emoji}`), + file("\u0001\u0002"), + part('name="f"; filename=""', "", [ + "Content-Type: application/octet-stream", + ]), + part('name="f"', "", ["Content-Type: application/octet-stream"]), + ), + ); + + equal(reply.status, 200); + deepEqual(names, [ + "abcdef.txt", + "invoicetxt.exe", + "a".repeat(255), + "я".repeat(127), + "a".repeat(253), + `${"a".repeat(251)}${emoji}`, + "blob", + "blob", + "blob", + ]); + }); +}); diff --git a/packages/multipart/test/options.test.ts b/packages/multipart/test/options.test.ts index d3c41241..4f99e13d 100644 --- a/packages/multipart/test/options.test.ts +++ b/packages/multipart/test/options.test.ts @@ -164,7 +164,10 @@ describe("Multipart (options)", () => { [Buffer.from("поле").toString("latin1"), "значение"], [ Buffer.from("файл").toString("latin1"), - Buffer.from("файл.txt").toString("latin1"), + // Without the control characters (e.g. `\x84`) of latin1 + Buffer.from("файл.txt") + .toString("latin1") + .replaceAll(/\p{Cc}/gu, ""), ], ]); }); From 166de71d6eb6e4a1a4ef00e4ba50aea82f315341 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 01:50:18 +0300 Subject: [PATCH 161/675] feat: add file_filter in multipart An application could refuse a file only once the whole request had been read into memory. `file_filter(info, context)` decides from what a file declares (its field, file name, media type and transfer encoding) before it is read: a refused file refuses the request with `415 Unsupported Media Type`, or, with `filtered: "skip"`, is read and discarded within the limits, while the other parts are kept in their order. It checks only the declared metadata, never the content: the media type is the one that the client has chosen. The filter may return a `Promise`. It is awaited in a race with the abort of the request, so that a filter that never settles does not keep the request, and a rejection that comes later is observed instead of crashing the process with an `unhandledRejection` (checked in a process of its own). Its result must be a boolean: anything else is a configuration error (`500`). A `BindenError` that it throws is the reply, any other error a `500`. Co-Authored-By: Claude Opus 5.5 --- packages/multipart/index.ts | 7 +- packages/multipart/src/multipart.ts | 83 +++++- packages/multipart/src/options.ts | 52 +++- packages/multipart/src/race.ts | 25 ++ packages/multipart/test/filter.test.ts | 257 ++++++++++++++++++ .../test/fixtures/late-rejections.ts | 71 +++++ packages/multipart/test/process.test.ts | 28 ++ 7 files changed, 507 insertions(+), 16 deletions(-) create mode 100644 packages/multipart/src/race.ts create mode 100644 packages/multipart/test/filter.test.ts create mode 100644 packages/multipart/test/fixtures/late-rejections.ts create mode 100644 packages/multipart/test/process.test.ts diff --git a/packages/multipart/index.ts b/packages/multipart/index.ts index d21514fe..be8ca7e8 100644 --- a/packages/multipart/index.ts +++ b/packages/multipart/index.ts @@ -8,7 +8,12 @@ export { type ILimitName, multipartInfo, } from "./src/info.js"; -export type { IMultipartConfig, IMultipartOptions } from "./src/options.js"; +export type { + IFileFilter, + IFileInfo, + IMultipartConfig, + IMultipartOptions, +} from "./src/options.js"; /** A `Multipart` of the options (see `IMultipartOptions`) */ export default (options?: IMultipartOptions): Multipart => diff --git a/packages/multipart/src/multipart.ts b/packages/multipart/src/multipart.ts index 14a0de96..03aaeef0 100644 --- a/packages/multipart/src/multipart.ts +++ b/packages/multipart/src/multipart.ts @@ -1,11 +1,13 @@ import { BindenError, type Context, Middleware } from "binden"; -import { type ILimitName, type IParseInfo, startParse } from "./info.js"; import { + type IFileFilter, + type IFileInfo, type IMultipartConfig, type IMultipartOptions, type IParserOptions, validateOptions, } from "./options.js"; +import { type ILimitName, type IParseInfo, startParse } from "./info.js"; import busboy, { type Busboy, type BusboyConfig, @@ -15,6 +17,7 @@ import busboy, { import { MultipartFile } from "./file.js"; import type { Readable } from "node:stream"; import { filenameOf } from "./filename.js"; +import { race } from "./race.js"; /** The logger of a context */ type ILog = Context["log"]; @@ -58,6 +61,13 @@ function create(config: BusboyConfig): Busboy | Error { } } +/** The error of a failed callback: a `BindenError` as is, any other a `500` */ +function failure(error: unknown, message: string): BindenError { + return error instanceof BindenError + ? error + : new BindenError(500, { message, cause: error }); +} + /** The content of a file */ async function collect(stream: Readable): Promise[]> { const chunks: Buffer[] = []; @@ -78,6 +88,10 @@ class Parser { readonly #config: IMultipartConfig; readonly #throw_limits: boolean; readonly #limit: number; + readonly #file_filter: IFileFilter | null; + readonly #filtered: "reject" | "skip"; + /** Aborted once the parse is over: what it awaits is abandoned */ + readonly #stop = new AbortController(); readonly #deferred = Promise.withResolvers(); readonly #slots: ISlot[] = []; readonly #tasks: Promise[] = []; @@ -90,7 +104,7 @@ class Parser { context: Context, log: ILog, info: IParseInfo, - { config, throw_limits, limit }: IParserOptions, + { config, throw_limits, limit, file_filter, filtered }: IParserOptions, ) { this.#context = context; this.#log = log; @@ -98,6 +112,8 @@ class Parser { this.#config = config; this.#throw_limits = throw_limits; this.#limit = limit; + this.#file_filter = file_filter; + this.#filtered = filtered; } /** Settles once the body is parsed (`request.body`), or refused */ @@ -209,7 +225,7 @@ class Parser { #onFile( name: string | undefined, stream: IFileStream, - { filename, mimeType }: FileInfo, + { filename, encoding, mimeType }: FileInfo, ): void { // Observed: busboy reports the errors of its streams itself stream.on("error", ignore); @@ -224,22 +240,60 @@ class Parser { stream.once("limit", () => { this.#limitReached("fileSize"); }); - const slot: ISlot = { name: name ?? "", value: null }; + const info: IFileInfo = { + field: name ?? "", + filename: filenameOf(filename), + mime_type: mimeType, + encoding, + }; + const slot: ISlot = { name: info.field, value: null }; this.#slots.push(slot); this.#tasks.push( - collect(stream).then( - (chunks) => { - slot.value = new MultipartFile(chunks, filenameOf(filename), { - type: mimeType, - truncated: stream.truncated === true, - }); - }, - // The parser reports why the stream has failed - ignore, - ), + this.#file(info, stream, slot).catch((error: unknown) => { + // E.g. `file_filter` has failed (the parser has already refused the + // request when its stream has failed) + this.#refuse(failure(error, "A file has not been handled")); + }), ); } + /** Reads a file that `file_filter` accepts (the content of the others is discarded) */ + async #file( + info: IFileInfo, + stream: IFileStream, + slot: ISlot, + ): Promise { + if (!(await this.#accepts(info))) { + if (this.#filtered === "reject") { + const message = "A file has been refused by `file_filter`"; + throw new BindenError(415, { message }); + } + stream.resume(); + return; + } + const chunks = await collect(stream); + slot.value = new MultipartFile(chunks, info.filename, { + type: info.mime_type, + truncated: stream.truncated === true, + }); + } + + /** Whether `file_filter` accepts a file (awaited until the parse is over) */ + async #accepts(info: IFileInfo): Promise { + const filter = this.#file_filter; + if (filter === null) { + return true; + } + const accepted: unknown = await race( + () => filter({ ...info }, this.#context), + this.#stop.signal, + ); + if (typeof accepted !== "boolean") { + throw new TypeError("`file_filter` has not returned a boolean"); + } + return accepted; + } + /** A limit on the count of the parts: the parts over it are dropped */ #countReached(limit: ILimitName): void { this.#info.limits_hit.push(limit); @@ -284,6 +338,7 @@ class Parser { return; } this.#end(); + this.#stop.abort(error); const { request, response } = this.#context; if (this.#busboy) { request.unpipe(this.#busboy); diff --git a/packages/multipart/src/options.ts b/packages/multipart/src/options.ts index 3aa9d7be..46b092c0 100644 --- a/packages/multipart/src/options.ts +++ b/packages/multipart/src/options.ts @@ -1,5 +1,5 @@ import type { BusboyConfig, Limits } from "busboy"; -import type { IMiddlewareParams } from "binden"; +import type { Context, IMiddlewareParams } from "binden"; /** * The configuration of busboy: its `limits`, the charsets of the parts and @@ -8,6 +8,33 @@ import type { IMiddlewareParams } from "binden"; */ export type IMultipartConfig = Omit; +/** + * What a file part declares of itself, none of which is checked against its + * content (e.g. its type is the one the client has chosen) + */ +export interface IFileInfo { + /** The name of the field */ + field: string; + /** + * The name of the file, as the client has sent it (see `filenameOf()`): + * metadata, never a path + */ + filename: string; + /** The media type of the part (`Content-Type`, `text/plain` by default) */ + mime_type: string; + /** The `Content-Transfer-Encoding` of the part (`7bit` by default) */ + encoding: string; +} + +/** + * Whether a file is accepted, from what it declares: a `Promise` is awaited + * (until the request is aborted), and must resolve to a boolean + */ +export type IFileFilter = ( + info: IFileInfo, + context: Context, +) => boolean | Promise; + /** The options of `Multipart` */ export interface IMultipartOptions extends IMiddlewareParams { /** The configuration of busboy, e.g. its `limits` (none by default but `fieldSize`, 1 MiB) */ @@ -23,6 +50,17 @@ export interface IMultipartOptions extends IMiddlewareParams { * gets a `413 Content Too Large`, whatever `throw_limits` */ limit?: number | undefined; + /** + * Whether a file is accepted, from what it declares (its metadata, never + * its content), before it is read: all of them by default + */ + file_filter?: IFileFilter | undefined; + /** + * What happens to a file that `file_filter` refuses: the request is + * refused with `415 Unsupported Media Type` (`"reject"`), or the file is + * read and discarded (`"skip"`) + */ + filtered?: "reject" | "skip" | undefined; } /** The options of a parse, checked, with their defaults */ @@ -30,6 +68,8 @@ export interface IParserOptions { readonly config: IMultipartConfig; readonly throw_limits: boolean; readonly limit: number; + readonly file_filter: IFileFilter | null; + readonly filtered: "reject" | "skip"; } /** The options of `Multipart` that its parent class takes */ @@ -41,6 +81,8 @@ const optionNames: ReadonlySet = new Set([ "config", "throw_limits", "limit", + "file_filter", + "filtered", ]); const configNames: ReadonlySet = new Set([ @@ -200,9 +242,15 @@ export function validateOptions(input: unknown): { config = {}, throw_limits = true, limit = Number.POSITIVE_INFINITY, + file_filter = null, + filtered = "reject", } = checkObject(input, "options", optionNames); if (typeof throw_limits !== "boolean") { throw new TypeError("`throw_limits` is not a boolean"); + } else if (file_filter !== null && typeof file_filter !== "function") { + throw new TypeError("`file_filter` is not a function"); + } else if (filtered !== "reject" && filtered !== "skip") { + throw new TypeError('`filtered` is not "reject" or "skip"'); } return { middleware: { @@ -213,6 +261,8 @@ export function validateOptions(input: unknown): { config: checkConfig(config), throw_limits, limit: checkSize(limit, "limit"), + file_filter: file_filter as IFileFilter | null, + filtered, }, }; } diff --git a/packages/multipart/src/race.ts b/packages/multipart/src/race.ts new file mode 100644 index 00000000..c83f2604 --- /dev/null +++ b/packages/multipart/src/race.ts @@ -0,0 +1,25 @@ +/** + * Calls `fn` and settles like its result, unless `signal` is aborted first + * (or already): it then rejects with the reason of the signal, and a later + * rejection of the result is observed, never an `unhandledRejection` + */ +export async function race( + fn: () => PromiseLike | T, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + const { promise: aborted, reject } = Promise.withResolvers(); + const abort = (): void => { + reject(signal.reason as Error); + }; + signal.addEventListener("abort", abort, { once: true }); + try { + const result = new Promise((settle) => { + settle(fn()); + }); + // Also observes a rejection of `result` that comes after the abort + return await Promise.race([result, aborted]); + } finally { + signal.removeEventListener("abort", abort); + } +} diff --git a/packages/multipart/test/filter.test.ts b/packages/multipart/test/filter.test.ts new file mode 100644 index 00000000..33c923b7 --- /dev/null +++ b/packages/multipart/test/filter.test.ts @@ -0,0 +1,257 @@ +import { BindenAbortError, BindenError, type Context } from "binden"; +import { + body, + never, + part, + post, + serve, + sleep, + type, + upload, + within, +} from "./helpers.js"; +import { deepEqual, equal, ok, rejects, throws } from "node:assert/strict"; +import { describe, it } from "node:test"; +import multipart, { + type IFileInfo, + type IMultipartOptions, + Multipart, +} from "../index.js"; +import { request as httpRequest } from "node:http"; + +/** A middleware that keeps the entries of the form: a file as its name */ +function keep(parsed: unknown[]): (context: Context) => Promise { + return async (context: Context): Promise => { + for (const [name, value] of context.request.body as FormData) { + parsed.push([name, typeof value === "string" ? value : value.name]); + } + await context.send(); + }; +} + +/** A form of a text file, a field and an image */ +const form = body( + part('name="text"; filename="a.txt"', "text"), + part('name="field"', "value"), + part('name="image"; filename="b.png"', "png", ["Content-Type: image/png"]), +); + +describe("Multipart (file_filter)", () => { + it("refuses a request of a filtered file with `415`", async (t) => { + let reached = false; + const { url } = await serve(t, [ + multipart({ file_filter: ({ mime_type }) => mime_type === "image/png" }), + (): void => { + reached = true; + }, + ]); + + const reply = await post(url, form); + + deepEqual([reply.status, reply.headers.connection], [415, "close"]); + equal(reached, false); + }); + + it('drops the filtered files with `filtered: "skip"`', async (t) => { + const parsed: unknown[] = []; + const infos: unknown[] = []; + const { url } = await serve(t, [ + multipart({ + filtered: "skip", + file_filter: (info: IFileInfo, context: Context): boolean => { + infos.push(info, context.request.method); + return info.mime_type === "image/png"; + }, + }), + keep(parsed), + ]); + + const reply = await post(url, form); + + equal(reply.status, 200); + deepEqual(parsed, [ + ["field", "value"], + ["image", "b.png"], + ]); + deepEqual(infos, [ + { + field: "text", + filename: "a.txt", + mime_type: "text/plain", + encoding: "7bit", + }, + "POST", + { + field: "image", + filename: "b.png", + mime_type: "image/png", + encoding: "7bit", + }, + "POST", + ]); + }); + + it("awaits a `file_filter` that returns a `Promise`, and keeps the order of the parts", async (t) => { + const parsed: unknown[] = []; + const { url } = await serve(t, [ + multipart({ + file_filter: async ({ field }): Promise => { + await sleep(field === "text" ? 30 : 0); + return true; + }, + }), + keep(parsed), + ]); + + const reply = await post(url, form); + + equal(reply.status, 200); + deepEqual(parsed, [ + ["text", "a.txt"], + ["field", "value"], + ["image", "b.png"], + ]); + }); + + it("still applies the limits to a skipped file", async (t) => { + const { url } = await serve(t, [ + multipart({ + filtered: "skip", + file_filter: () => false, + config: { limits: { fileSize: 4096 } }, + }), + ]); + + // The limit is reached by the second chunk, once the file is skipped + const data = body(part('name="a"; filename="a.bin"', "a".repeat(8192))); + const reply = await new Promise((resolve, reject) => { + const client = httpRequest(url, { + method: "POST", + headers: { "Content-Type": type, "Content-Length": data.length }, + signal: AbortSignal.timeout(5000), + }); + client + .once("response", (response) => { + response.resume(); + resolve(response.statusCode); + }) + .once("error", reject) + .write(data.slice(0, 1024)); + setTimeout(() => { + client.end(data.slice(1024)); + }, 50); + }); + + equal(reply, 413); + }); + + it("replies `500` to a result that is not a boolean", async (t) => { + let result: unknown = null; + const { url } = await serve(t, [ + multipart({ file_filter: () => result as boolean }), + ]); + + const statuses: number[] = []; + for (const value of ["yes", 1, null, Promise.resolve("true")]) { + result = value; + statuses.push((await post(url, form)).status); + } + + deepEqual(statuses, [500, 500, 500, 500]); + }); + + it("replies with the status of a failure of `file_filter` (`500` but for a `BindenError`)", async (t) => { + let fail = (): Promise => Promise.resolve(true); + const { url } = await serve(t, [multipart({ file_filter: () => fail() })]); + + const statuses: number[] = []; + for (const failure of [ + (): Promise => { + throw new BindenError(403); + }, + (): Promise => { + throw new Error("A failure"); + }, + (): Promise => Promise.reject(new BindenError(422)), + async (): Promise => { + await sleep(1); + throw new TypeError("A late failure"); + }, + ]) { + fail = failure; + statuses.push((await post(url, form)).status); + } + + deepEqual(statuses, [403, 500, 422, 500]); + }); + + it("stops awaiting a `file_filter` that never settles once the request is aborted", async (t) => { + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const parser = new Multipart({ file_filter: never }); + const { url } = await serve( + t, + [ + async (context: Context): Promise => { + await parser.run(context).catch((error: unknown) => { + settle(error); + throw error; + }); + }, + ], + { timeout: 100 }, + ); + + const reply = await upload(url, part('name="a"; filename="a"', "a")); + const error = await within(settled); + + deepEqual(reply, [503, "close"]); + ok(error instanceof BindenError); + ok(error.cause instanceof BindenAbortError); + deepEqual( + [error.status, error.code, error.cause.reason], + [400, "aborted", "deadline"], + ); + }); + + it("refuses an invalid `file_filter` or `filtered`", () => { + const invalid: [unknown, Error][] = [ + [{ file_filter: true }, new TypeError("`file_filter` is not a function")], + [ + { filtered: "drop" }, + new TypeError('`filtered` is not "reject" or "skip"'), + ], + ]; + for (const [options, error] of invalid) { + throws(() => new Multipart(options as IMultipartOptions), error); + } + }); +}); + +describe("race()", () => { + it("settles like the result, unless the signal is aborted first", async () => { + const { race } = await import("../src/race.js"); + const controller = new AbortController(); + const reason = new Error("aborted"); + const called: string[] = []; + + equal(await race(() => 1, controller.signal), 1); + equal(await race(() => Promise.resolve(2), controller.signal), 2); + await rejects( + race(() => { + throw new Error("thrown"); + }, controller.signal), + new Error("thrown"), + ); + const pending = race(never, controller.signal); + controller.abort(reason); + await rejects(within(pending), reason); + await rejects( + race(() => { + called.push("called"); + }, controller.signal), + reason, + ); + deepEqual(called, []); + }); +}); diff --git a/packages/multipart/test/fixtures/late-rejections.ts b/packages/multipart/test/fixtures/late-rejections.ts new file mode 100644 index 00000000..753fa840 --- /dev/null +++ b/packages/multipart/test/fixtures/late-rejections.ts @@ -0,0 +1,71 @@ +// Run by `process.test.ts`: callbacks that never settle, or that fail once +// the request has been aborted, must neither crash the process +// (`unhandledRejection`) nor keep a request without a reply +import { Binden, type Context } from "binden"; +import { type IncomingMessage, request } from "node:http"; +import type { AddressInfo } from "node:net"; +import multipart from "../../index.js"; +import { once } from "node:events"; +import { setTimeout as sleep } from "node:timers/promises"; + +process.on("unhandledRejection", () => { + process.stderr.write("unhandledRejection\n"); + process.exitCode = 1; +}); +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const never = (): Promise => + new Promise(() => { + // Never settles + }); +const failsLate = async (): Promise => { + await sleep(80); + throw new Error("A late failure"); +}; + +const app = new Binden({ timeout: 30 }) + .use("/hangs", multipart({ file_filter: never })) + .use("/fails-late", multipart({ file_filter: failsLate })) + .use((context: Context) => context.send()); + +const server = app.createServer().listen(0); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const boundary = "binden-boundary"; +const head = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="file"; filename="file.txt"', + "", + "Hello", +].join("\r\n"); + +const statuses: number[] = []; +for (const path of ["/hangs", "/fails-late"]) { + const response = await new Promise((resolve, reject) => { + request({ + agent: false, + method: "POST", + path, + port, + headers: { + "Content-Type": `multipart/form-data; boundary=${boundary}`, + "Content-Length": 1024, + }, + }) + .once("response", resolve) + .once("error", reject) + .write(head); + }); + response.resume(); + await once(response, "end"); + statuses.push(response.statusCode ?? 0); +} + +// The late failures come after the replies +await sleep(150); +server.close(); +process.stdout.write(`${statuses.join(" ")}\n`); diff --git a/packages/multipart/test/process.test.ts b/packages/multipart/test/process.test.ts new file mode 100644 index 00000000..2caaa148 --- /dev/null +++ b/packages/multipart/test/process.test.ts @@ -0,0 +1,28 @@ +import { suite, test } from "node:test"; +import { deepStrictEqual } from "node:assert"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** + * Runs a fixture (`test/fixtures`) in a process of its own: a crash (e.g. an + * `unhandledRejection`) is its exit code and output, not a failure of the + * test runner + */ +function run(fixture: string): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + ["--import", "tsx", join(import.meta.dirname, "fixtures", fixture)], + { encoding: "utf8", timeout: 30_000 }, + ); + return { status, stdout, stderr }; +} + +suite("Multipart (process safety)", () => { + test("callbacks that hang or fail once the request is aborted do not crash the process", () => { + deepStrictEqual(run("late-rejections.ts"), { + status: 0, + stdout: "503 503\n", + stderr: "", + }); + }); +}); From 4f39603a05ea00081899ff028afc10ecd8a7fbd7 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 01:57:03 +0300 Subject: [PATCH 162/675] feat: add the stream storage in multipart Every file was read into memory before the application could see it: a service that forwards uploads (e.g. to an object storage) needed as much memory as the uploads, and nothing bounded it but the limits. With `storage: { on_file }` each file goes to `on_file(file, context)` as a stream, with what it declares and `truncated()`, and `request.body` is a `FormData` of the fields only. `run()` awaits the parser and every handler: what a handler has not read once it has settled is read and discarded, within the limits. A handler is awaited in a race with the abort of the request, and a rejection that comes later is observed. A handler that fails, or that destroys its stream, fails the request with its `BindenError`, or a `500`: the client gets the status even in the middle of its upload. The streams of a refused request are destroyed. The memory stays bounded whatever the size of the files: a process of its own streams 128 MiB, and its memory grows by less than 32 MiB, when the memory storage grows by the size of the file. Co-Authored-By: Claude Opus 5.5 --- packages/multipart/index.ts | 3 + packages/multipart/src/multipart.ts | 46 ++- packages/multipart/src/options.ts | 56 ++++ .../test/fixtures/late-rejections.ts | 9 +- .../multipart/test/fixtures/stream-memory.ts | 75 +++++ packages/multipart/test/process.test.ts | 30 +- packages/multipart/test/stream.test.ts | 317 ++++++++++++++++++ 7 files changed, 530 insertions(+), 6 deletions(-) create mode 100644 packages/multipart/test/fixtures/stream-memory.ts create mode 100644 packages/multipart/test/stream.test.ts diff --git a/packages/multipart/index.ts b/packages/multipart/index.ts index be8ca7e8..6a03b720 100644 --- a/packages/multipart/index.ts +++ b/packages/multipart/index.ts @@ -13,6 +13,9 @@ export type { IFileInfo, IMultipartConfig, IMultipartOptions, + IStorage, + IStreamFile, + IStreamStorage, } from "./src/options.js"; /** A `Multipart` of the options (see `IMultipartOptions`) */ diff --git a/packages/multipart/src/multipart.ts b/packages/multipart/src/multipart.ts index 03aaeef0..eaf27be2 100644 --- a/packages/multipart/src/multipart.ts +++ b/packages/multipart/src/multipart.ts @@ -2,9 +2,12 @@ import { BindenError, type Context, Middleware } from "binden"; import { type IFileFilter, type IFileInfo, + type IMode, type IMultipartConfig, type IMultipartOptions, type IParserOptions, + type IStreamFile, + type IStreamStorage, validateOptions, } from "./options.js"; import { type ILimitName, type IParseInfo, startParse } from "./info.js"; @@ -90,6 +93,7 @@ class Parser { readonly #limit: number; readonly #file_filter: IFileFilter | null; readonly #filtered: "reject" | "skip"; + readonly #mode: IMode; /** Aborted once the parse is over: what it awaits is abandoned */ readonly #stop = new AbortController(); readonly #deferred = Promise.withResolvers(); @@ -104,7 +108,14 @@ class Parser { context: Context, log: ILog, info: IParseInfo, - { config, throw_limits, limit, file_filter, filtered }: IParserOptions, + { + config, + throw_limits, + limit, + file_filter, + filtered, + mode, + }: IParserOptions, ) { this.#context = context; this.#log = log; @@ -114,6 +125,7 @@ class Parser { this.#limit = limit; this.#file_filter = file_filter; this.#filtered = filtered; + this.#mode = mode; } /** Settles once the body is parsed (`request.body`), or refused */ @@ -162,7 +174,7 @@ class Parser { }, ) .on("file", (name: string | undefined, stream, info) => { - this.#onFile(name, stream, info); + this.#onFile(parser, name, stream, info); }) .once("fieldsLimit", () => { this.#countReached("fields"); @@ -223,6 +235,7 @@ class Parser { } #onFile( + parser: Busboy, name: string | undefined, stream: IFileStream, { filename, encoding, mimeType }: FileInfo, @@ -236,6 +249,11 @@ class Parser { this.#streams.add(stream); stream.once("close", () => { this.#streams.delete(stream); + if (!stream.readableEnded && !parser.destroyed) { + // E.g. `on_file` has destroyed it: busboy does not go on without it + const message = "The stream of a file has been closed before its end"; + this.#refuse(failure(stream.errored, message)); + } }); stream.once("limit", () => { this.#limitReached("fileSize"); @@ -270,6 +288,9 @@ class Parser { } stream.resume(); return; + } else if (this.#mode.kind === "stream") { + await this.#stream(this.#mode.on_file, info, stream); + return; } const chunks = await collect(stream); slot.value = new MultipartFile(chunks, info.filename, { @@ -278,6 +299,27 @@ class Parser { }); } + /** + * Streams a file to `on_file` (awaited until the parse is over): what it + * has not read once it has settled is read and discarded + */ + async #stream( + on_file: IStreamStorage["on_file"], + info: IFileInfo, + stream: IFileStream, + ): Promise { + const file: IStreamFile = { + ...info, + stream, + truncated: () => stream.truncated === true, + }; + await race(() => on_file(file, this.#context), this.#stop.signal); + if (!stream.readableEnded) { + stream.unpipe(); + stream.resume(); + } + } + /** Whether `file_filter` accepts a file (awaited until the parse is over) */ async #accepts(info: IFileInfo): Promise { const filter = this.#file_filter; diff --git a/packages/multipart/src/options.ts b/packages/multipart/src/options.ts index 46b092c0..898d04df 100644 --- a/packages/multipart/src/options.ts +++ b/packages/multipart/src/options.ts @@ -1,5 +1,6 @@ import type { BusboyConfig, Limits } from "busboy"; import type { Context, IMiddlewareParams } from "binden"; +import type { Readable } from "node:stream"; /** * The configuration of busboy: its `limits`, the charsets of the parts and @@ -35,6 +36,38 @@ export type IFileFilter = ( context: Context, ) => boolean | Promise; +/** A file of the stream storage: what it declares, and its content */ +export interface IStreamFile extends IFileInfo { + /** + * The content: what `on_file` has not read once it has settled is read + * and discarded + */ + readonly stream: Readable; + /** Whether the content has been truncated to `fileSize` (known once it has been read) */ + truncated(): boolean; +} + +/** + * Streams each file to `on_file` (e.g. to a storage service) instead of + * keeping it: `request.body` is a `FormData` of the fields only + */ +export interface IStreamStorage { + /** + * Handles a file: it should read the whole stream before it settles. It is + * awaited (until the request is aborted) before the body is parsed; it + * fails the request with a `BindenError` it throws, or a `500`. + */ + on_file(file: IStreamFile, context: Context): unknown; +} + +/** Where the files go: kept in memory (`"memory"`), or streamed to a handler */ +export type IStorage = "memory" | IStreamStorage; + +/** The storage of a parse */ +export type IMode = + | { readonly kind: "memory" } + | { readonly kind: "stream"; readonly on_file: IStreamStorage["on_file"] }; + /** The options of `Multipart` */ export interface IMultipartOptions extends IMiddlewareParams { /** The configuration of busboy, e.g. its `limits` (none by default but `fieldSize`, 1 MiB) */ @@ -61,6 +94,11 @@ export interface IMultipartOptions extends IMiddlewareParams { * read and discarded (`"skip"`) */ filtered?: "reject" | "skip" | undefined; + /** + * Where the files go: in memory, as a `MultipartFile` of the `FormData` + * (`"memory"`), or to a handler (`{ on_file }`) + */ + storage?: IStorage | undefined; } /** The options of a parse, checked, with their defaults */ @@ -70,6 +108,7 @@ export interface IParserOptions { readonly limit: number; readonly file_filter: IFileFilter | null; readonly filtered: "reject" | "skip"; + readonly mode: IMode; } /** The options of `Multipart` that its parent class takes */ @@ -83,8 +122,11 @@ const optionNames: ReadonlySet = new Set([ "limit", "file_filter", "filtered", + "storage", ]); +const storageNames: ReadonlySet = new Set(["on_file"]); + const configNames: ReadonlySet = new Set([ "headers", "limits", @@ -227,6 +269,18 @@ function checkConfig(value: unknown): IMultipartConfig { }; } +/** The storage of the files */ +function checkStorage(value: unknown): IMode { + if (value === "memory") { + return { kind: "memory" }; + } + const { on_file } = checkObject(value, "storage", storageNames); + if (typeof on_file !== "function") { + throw new TypeError("`storage.on_file` is not a function"); + } + return { kind: "stream", on_file: on_file as IStreamStorage["on_file"] }; +} + /** * The options of `Multipart`, checked (a `TypeError` for an unknown option * or a value of another type, a `RangeError` for a value out of its range), @@ -244,6 +298,7 @@ export function validateOptions(input: unknown): { limit = Number.POSITIVE_INFINITY, file_filter = null, filtered = "reject", + storage = "memory", } = checkObject(input, "options", optionNames); if (typeof throw_limits !== "boolean") { throw new TypeError("`throw_limits` is not a boolean"); @@ -263,6 +318,7 @@ export function validateOptions(input: unknown): { limit: checkSize(limit, "limit"), file_filter: file_filter as IFileFilter | null, filtered, + mode: checkStorage(storage), }, }; } diff --git a/packages/multipart/test/fixtures/late-rejections.ts b/packages/multipart/test/fixtures/late-rejections.ts index 753fa840..e7feaeac 100644 --- a/packages/multipart/test/fixtures/late-rejections.ts +++ b/packages/multipart/test/fixtures/late-rejections.ts @@ -29,6 +29,8 @@ const failsLate = async (): Promise => { const app = new Binden({ timeout: 30 }) .use("/hangs", multipart({ file_filter: never })) .use("/fails-late", multipart({ file_filter: failsLate })) + .use("/on-file-hangs", multipart({ storage: { on_file: never } })) + .use("/on-file-fails-late", multipart({ storage: { on_file: failsLate } })) .use((context: Context) => context.send()); const server = app.createServer().listen(0); @@ -44,7 +46,12 @@ const head = [ ].join("\r\n"); const statuses: number[] = []; -for (const path of ["/hangs", "/fails-late"]) { +for (const path of [ + "/hangs", + "/fails-late", + "/on-file-hangs", + "/on-file-fails-late", +]) { const response = await new Promise((resolve, reject) => { request({ agent: false, diff --git a/packages/multipart/test/fixtures/stream-memory.ts b/packages/multipart/test/fixtures/stream-memory.ts new file mode 100644 index 00000000..2e0d59e9 --- /dev/null +++ b/packages/multipart/test/fixtures/stream-memory.ts @@ -0,0 +1,75 @@ +// Run by `process.test.ts` (with `--expose-gc`): the memory of the stream +// storage stays bounded, whatever the size of the files it streams +import { Binden, type Context } from "binden"; +import { type IncomingMessage, request } from "node:http"; +import multipart, { type IMultipartOptions } from "../../index.js"; +import type { AddressInfo } from "node:net"; +import { Readable } from "node:stream"; +import { once } from "node:events"; +import { pipeline } from "node:stream/promises"; + +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 25_000).unref(); + +const MiB = 1 << 20; +/** The size of the file: much more than the growth of the memory allowed */ +const size = 128 * MiB; +const chunk = Buffer.alloc(64 * 1024, "a"); +const boundary = "binden-boundary"; + +let received = 0; +const storage: IMultipartOptions["storage"] = + process.argv[2] === "memory" + ? "memory" + : { + on_file: async ({ stream }): Promise => { + for await (const data of stream) { + received += (data as Buffer).length; + } + }, + }; +const app = new Binden() + .use(multipart({ storage })) + .use((context: Context) => context.send()); +const server = app.createServer().listen(0); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +function* upload(): Generator { + yield `--${boundary}\r\nContent-Disposition: form-data; name="f"; filename="f.bin"\r\n\r\n`; + for (let sent = 0; sent < size; sent += chunk.length) { + yield chunk; + } + yield `\r\n--${boundary}--\r\n`; +} + +globalThis.gc?.(); +const baseline = process.memoryUsage().rss; +let peak = baseline; +// The garbage is collected first: only what is kept counts +const sampler = setInterval(() => { + globalThis.gc?.(); + peak = Math.max(peak, process.memoryUsage().rss); +}, 20); + +const client = request({ + agent: false, + method: "POST", + port, + headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` }, +}); +const responded = once(client, "response") as Promise<[IncomingMessage]>; +await pipeline(Readable.from(upload()), client); +const [response] = await responded; +response.resume(); +await once(response, "end"); +clearInterval(sampler); +peak = Math.max(peak, process.memoryUsage().rss); +server.close(); + +const growth = Math.round((peak - baseline) / MiB); +process.stdout.write( + `${response.statusCode} ${received / MiB} ${growth < 32 ? "bounded" : "unbounded"}\n`, +); diff --git a/packages/multipart/test/process.test.ts b/packages/multipart/test/process.test.ts index 2caaa148..f840c906 100644 --- a/packages/multipart/test/process.test.ts +++ b/packages/multipart/test/process.test.ts @@ -8,10 +8,19 @@ import { spawnSync } from "node:child_process"; * `unhandledRejection`) is its exit code and output, not a failure of the * test runner */ -function run(fixture: string): Record { +function run( + fixture: string, + { flags = [], args = [] }: { flags?: string[]; args?: string[] } = {}, +): Record { const { status, stdout, stderr } = spawnSync( process.execPath, - ["--import", "tsx", join(import.meta.dirname, "fixtures", fixture)], + [ + ...flags, + "--import", + "tsx", + join(import.meta.dirname, "fixtures", fixture), + ...args, + ], { encoding: "utf8", timeout: 30_000 }, ); return { status, stdout, stderr }; @@ -21,7 +30,22 @@ suite("Multipart (process safety)", () => { test("callbacks that hang or fail once the request is aborted do not crash the process", () => { deepStrictEqual(run("late-rejections.ts"), { status: 0, - stdout: "503 503\n", + stdout: "503 503 503 503\n", + stderr: "", + }); + }); + + test("the stream storage keeps a bounded memory, whatever the size of the files", () => { + const flags = ["--expose-gc"]; + deepStrictEqual(run("stream-memory.ts", { flags }), { + status: 0, + stdout: "200 128 bounded\n", + stderr: "", + }); + // Unlike the memory storage, which keeps them + deepStrictEqual(run("stream-memory.ts", { flags, args: ["memory"] }), { + status: 0, + stdout: "200 0 unbounded\n", stderr: "", }); }); diff --git a/packages/multipart/test/stream.test.ts b/packages/multipart/test/stream.test.ts new file mode 100644 index 00000000..c0a63d4d --- /dev/null +++ b/packages/multipart/test/stream.test.ts @@ -0,0 +1,317 @@ +import { BindenAbortError, BindenError, type Context } from "binden"; +import { + body, + never, + part, + post, + serve, + sleep, + type, + upload, + within, +} from "./helpers.js"; +import { deepEqual, equal, ok, throws } from "node:assert/strict"; +import { describe, it } from "node:test"; +import multipart, { + type IMultipartOptions, + type IStreamFile, + Multipart, +} from "../index.js"; +import type { Readable } from "node:stream"; +import { request as httpRequest } from "node:http"; +import { once } from "node:events"; + +/** The content of a stream */ +async function read(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString(); +} + +/** A middleware that keeps the entries of the form */ +function keep(parsed: unknown[]): (context: Context) => Promise { + return async (context: Context): Promise => { + parsed.push([...(context.request.body as FormData)]); + await context.send(); + }; +} + +/** A form of two files and two fields */ +const form = body( + part('name="a"; filename="a.txt"', "Hello"), + part('name="f1"', "v1"), + part('name="b"; filename="b.png"', "World", ["Content-Type: image/png"]), + part('name="f2"', "v2"), +); + +describe("Multipart (stream storage)", () => { + it("streams the files to `on_file`, and awaits it", async (t) => { + const handled: unknown[] = []; + const parsed: unknown[] = []; + const { url } = await serve(t, [ + multipart({ + storage: { + on_file: async (file: IStreamFile, context: Context) => { + const { field, filename, mime_type, encoding } = file; + const content = await read(file.stream); + await sleep(20); + handled.push([ + field, + filename, + mime_type, + encoding, + content, + file.truncated(), + context.request.method, + ]); + }, + }, + }), + async (context: Context): Promise => { + parsed.push(handled.length); + await keep(parsed)(context); + }, + ]); + + const reply = await post(url, form); + + equal(reply.status, 200); + deepEqual(handled, [ + ["a", "a.txt", "text/plain", "7bit", "Hello", false, "POST"], + ["b", "b.png", "image/png", "7bit", "World", false, "POST"], + ]); + deepEqual(parsed, [ + 2, + [ + ["f1", "v1"], + ["f2", "v2"], + ], + ]); + }); + + it("reads and discards what `on_file` has not read", async (t) => { + const parsed: unknown[] = []; + const { url } = await serve(t, [ + multipart({ + storage: { + on_file: ({ field, stream }: IStreamFile): unknown => { + if (field === "b") { + // Paused once it has a chunk: the rest is not read + return new Promise((resolve) => { + stream.once("data", () => { + stream.pause(); + resolve(null); + }); + }); + } + return null; + }, + }, + }), + keep(parsed), + ]); + + const reply = await post( + url, + body( + part('name="a"; filename="a.txt"', "Hello"), + part('name="f1"', "v1"), + part('name="b"; filename="b.txt"', "a".repeat(1 << 18)), + part('name="f2"', "v2"), + ), + ); + + equal(reply.status, 200); + deepEqual(parsed, [ + [ + ["f1", "v1"], + ["f2", "v2"], + ], + ]); + }); + + it("tells whether a file has been truncated (`throw_limits: false`)", async (t) => { + const handled: unknown[] = []; + const { url } = await serve(t, [ + multipart({ + throw_limits: false, + config: { limits: { fileSize: 3 } }, + storage: { + on_file: async (file: IStreamFile) => { + handled.push([await read(file.stream), file.truncated()]); + }, + }, + }), + ]); + + await post(url, form); + + deepEqual(handled, [ + ["Hel", true], + ["Wor", true], + ]); + }); + + it("replies with the status of a failure of `on_file` (`500` but for a `BindenError`)", async (t) => { + let fail = (): unknown => null; + const { url } = await serve(t, [ + multipart({ storage: { on_file: () => fail() } }), + ]); + + const statuses: number[] = []; + for (const failure of [ + (): unknown => { + throw new BindenError(422); + }, + (): unknown => { + throw new Error("A failure"); + }, + (): unknown => Promise.reject(new BindenError(507)), + async (): Promise => { + await sleep(1); + throw new TypeError("A late failure"); + }, + ]) { + fail = failure; + statuses.push((await post(url, form)).status); + } + + deepEqual(statuses, [422, 500, 507, 500]); + }); + + it("replies to the client with the status of a failure in the middle of an upload", async (t) => { + let received = 0; + const { url } = await serve(t, [ + multipart({ + storage: { + on_file: async ({ stream }: IStreamFile) => { + const [chunk] = (await once(stream, "data")) as [Buffer]; + received += chunk.length; + throw new BindenError(422); + }, + }, + }), + ]); + + // The body is never complete: the reply comes before it + const reply = await upload( + url, + part('name="file"; filename="file.bin"', "a".repeat(1024)), + ); + + deepEqual(reply, [422, "close"]); + ok(received > 0); + }); + + it("refuses the request when `on_file` destroys the stream", async (t) => { + let error: Error | undefined = new BindenError(415); + const { url } = await serve(t, [ + multipart({ + storage: { + on_file: ({ stream }: IStreamFile): void => { + stream.destroy(error); + }, + }, + }), + ]); + + const statuses = [(await post(url, form)).status]; + error = new Map().get("none"); + statuses.push((await post(url, form)).status); + + deepEqual(statuses, [415, 500]); + }); + + it("destroys the streams of a refused request", async (t) => { + const { promise: closed, resolve: close } = Promise.withResolvers(); + const { url } = await serve(t, [ + multipart({ + limit: 1 << 16, + storage: { + on_file: async ({ field, stream }: IStreamFile) => { + if (field === "b") { + await read(stream); + } else { + // Not read: busboy has pushed it whole, and gone on + await once(stream, "close"); + close(null); + } + }, + }, + }), + ]); + + // The request is refused by the second chunk + const data = body( + part('name="a"; filename="a.txt"', "a"), + part('name="b"; filename="b.bin"', "b".repeat(1 << 17)), + ); + const reply = await new Promise((resolve, reject) => { + const client = httpRequest(url, { + method: "POST", + headers: { "Content-Type": type, "Transfer-Encoding": "chunked" }, + signal: AbortSignal.timeout(5000), + }); + client + .once("response", (response) => { + response.resume(); + resolve(response.statusCode); + client.destroy(); + }) + .once("error", reject) + .write(data.slice(0, 1024)); + setTimeout(() => { + client.write(data.slice(1024)); + }, 50); + }); + + equal(reply, 413); + equal(await within(closed), null); + }); + + it("stops awaiting an `on_file` that never settles once the request is aborted", async (t) => { + const { promise: settled, resolve: settle } = + Promise.withResolvers(); + const parser = new Multipart({ storage: { on_file: never } }); + const { url } = await serve(t, [ + async (context: Context): Promise => { + await parser.run(context).then(settle, settle); + }, + ]); + + const client = httpRequest(url, { + method: "POST", + headers: { "Content-Type": type }, + }); + client.on("error", () => { + // The connection is closed on purpose + }); + client.end(form); + await sleep(50); + client.destroy(); + + const error = await within(settled); + ok(error instanceof BindenError); + ok(error.cause instanceof BindenAbortError); + deepEqual( + [error.status, error.code, error.cause.reason], + [400, "aborted", "client_closed"], + ); + }); + + it("refuses an invalid `storage`", () => { + const invalid: [unknown, Error][] = [ + ["disk", new TypeError("`storage` is not an object")], + [{}, new TypeError("`storage.on_file` is not a function")], + [ + { on_file: (): null => null, dir: "/tmp" }, + new TypeError("Unknown option: `storage.dir`"), + ], + ]; + for (const [storage, error] of invalid) { + throws(() => new Multipart({ storage } as IMultipartOptions), error); + } + }); +}); From a814e1defa3e61f095cb3cca3b2522d338d6b69f Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 02:11:06 +0300 Subject: [PATCH 163/675] feat: add the disk storage in multipart Large files could only be kept in memory, or streamed by the application itself. With `storage: {}` (or `{ dir }`) each file is written to the disk and `request.body` is `{ fields, files }`: the fields, and the stored files (their path, size and truncation), by name in their order. The files of a request go to a private directory (`mkdtemp`, `0700`) under `dir` (resolved when the middleware is created) or `os.tmpdir()`, each in a file of its own, named by a random UUID and opened with `O_EXCL` and `0600`: the name that the client sends is never a path. The directory is removed with every file that has not been persisted once the response is closed (`context.onCleanup`), also when the request is refused or aborted: the writes are stopped, their descriptors closed, the files removed, and every failure reported. `persist(target)` keeps a file: a hard link, or a copy that never replaces an existing file to another file system (`EXDEV`), whose partial target is removed when it fails. An existing file or symbolic link at the target is an `EEXIST` error. It waits for the file to be written, and is serialized with the cleanup, so that the cleanup never removes a file being persisted (nor one that has been); the cleanup takes the file over again when the move fails. Persisting it again to the same path does nothing, to another path throws. Co-Authored-By: Claude Opus 5.5 --- packages/multipart/index.ts | 2 + packages/multipart/src/disk.ts | 300 +++++++++++++++ packages/multipart/src/multipart.ts | 60 ++- packages/multipart/src/options.ts | 42 ++- packages/multipart/test/disk-store.test.ts | 346 +++++++++++++++++ packages/multipart/test/disk.test.ts | 419 +++++++++++++++++++++ packages/multipart/test/helpers.ts | 14 + packages/multipart/test/stream.test.ts | 10 +- 8 files changed, 1175 insertions(+), 18 deletions(-) create mode 100644 packages/multipart/src/disk.ts create mode 100644 packages/multipart/test/disk-store.test.ts create mode 100644 packages/multipart/test/disk.test.ts diff --git a/packages/multipart/index.ts b/packages/multipart/index.ts index 6a03b720..911718ed 100644 --- a/packages/multipart/index.ts +++ b/packages/multipart/index.ts @@ -3,12 +3,14 @@ import { Multipart } from "./src/multipart.js"; export * from "./src/multipart.js"; export * from "./src/file.js"; +export type { IStoredFile, IStoredForm } from "./src/disk.js"; export { type IMultipartInfo, type ILimitName, multipartInfo, } from "./src/info.js"; export type { + IDiskStorage, IFileFilter, IFileInfo, IMultipartConfig, diff --git a/packages/multipart/src/disk.ts b/packages/multipart/src/disk.ts new file mode 100644 index 00000000..c0d4d3a1 --- /dev/null +++ b/packages/multipart/src/disk.ts @@ -0,0 +1,300 @@ +import { type WriteStream, constants } from "node:fs"; +import fs, { type FileHandle } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import type { IFileInfo } from "./options.js"; +import type { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { randomUUID } from "node:crypto"; + +/** A file of the disk storage: what it declares, and where it is stored */ +export interface IStoredFile extends IFileInfo { + /** + * Where the file is: a file of its own (`0600`, named by a random UUID) in + * a private directory (`0700`) of the request, which is removed with it + * once the response is closed, or where it has been persisted + */ + readonly path: string; + /** Its size in bytes */ + readonly size: number; + /** Whether it has been truncated to `fileSize` */ + readonly truncated: boolean; + /** + * Moves the file to `target` (which must not exist: a file or a symbolic + * link there is an `EEXIST` error), so that it is kept once the response + * is closed: by a hard link, or a copy to another file system. Persisting + * it again to the same path does nothing, to another one throws. + */ + persist(target: string): Promise; +} + +/** The body that the disk storage sets: the fields and the stored files, by name, in their order */ +export interface IStoredForm { + readonly fields: Map; + readonly files: Map; +} + +/** The operations of the file system of the disk storage (replaced by the tests) */ +export interface IFileSystem { + mkdtemp(prefix: string): Promise; + open(path: string, flags: number, mode: number): Promise; + link(existing: string, target: string): Promise; + copyFile(source: string, target: string, mode: number): Promise; + unlink(path: string): Promise; + rmdir(path: string): Promise; +} + +/** A stream of the content of a file (`truncated` once it has been cut to `fileSize`) */ +export type IFileStream = Readable & { truncated?: boolean }; + +/** Ignores an error that is reported otherwise */ +function ignore(): void { + // Reported otherwise +} + +/** The `code` of a system error */ +function codeOf(error: unknown): unknown { + return (error as NodeJS.ErrnoException).code; +} + +/** Removes a file, unless it does not exist */ +async function unlinkIfExists( + system: IFileSystem, + path: string, +): Promise { + try { + await system.unlink(path); + } catch (error: unknown) { + if (codeOf(error) !== "ENOENT") { + throw error; + } + } +} + +/** + * A file that the disk storage writes. Persisting it and removing it are + * serialized: one waits for the other, so that the cleanup of the request + * never removes a file that is being (or has been) persisted. + */ +export class StoredFile implements IStoredFile { + public readonly field: string; + public readonly filename: string; + public readonly mime_type: string; + public readonly encoding: string; + /** Settles once the file is written (and its descriptor closed) */ + public readonly written: Promise; + readonly #system: IFileSystem; + /** The temporary file, until it is removed */ + #temp: string | null = null; + /** Where the file has been persisted */ + #target: string | null = null; + #path = ""; + #size = 0; + #truncated = false; + #handle: FileHandle | null = null; + #output: WriteStream | null = null; + #stopped = false; + #lock: Promise = Promise.resolve(); + + public constructor( + { field, filename, mime_type, encoding }: IFileInfo, + system: IFileSystem, + directory: Promise, + stream: IFileStream, + ) { + this.field = field; + this.filename = filename; + this.mime_type = mime_type; + this.encoding = encoding; + this.#system = system; + this.written = this.#write(directory, stream); + } + + public get path(): string { + return this.#path; + } + + public get size(): number { + return this.#size; + } + + public get truncated(): boolean { + return this.#truncated; + } + + public async persist(target: string): Promise { + if (typeof target !== "string" || target === "") { + throw new TypeError("`target` is not a non-empty string"); + } + const path = resolve(target); + await this.written; + await this.#exclusive(async () => { + if (this.#target !== null) { + if (this.#target !== path) { + throw new Error("The file has already been persisted elsewhere"); + } + return; + } + const temp = this.#temp; + if (temp === null) { + throw new Error("The file has been removed"); + } + await this.#move(temp, path); + // Owned by the application from now on: never removed by the cleanup + this.#target = path; + this.#path = path; + try { + await this.#system.unlink(temp); + this.#temp = null; + } catch { + // The cleanup removes it + } + }); + } + + /** Stops writing the file (its content is no longer read) */ + public stop(): void { + this.#stopped = true; + this.#output?.destroy(); + } + + /** Closes the descriptor of the file, if it is open */ + public async close(): Promise { + await this.#handle?.close(); + } + + /** Removes the temporary file, unless it has been persisted: it can no longer be persisted */ + public remove(): Promise { + return this.#exclusive(async () => { + const temp = this.#temp; + this.#temp = null; + if (temp !== null) { + await unlinkIfExists(this.#system, temp); + } + }); + } + + /** Writes the content into a new private file of the directory */ + async #write(directory: Promise, stream: IFileStream): Promise { + const path = join(await directory, randomUUID()); + const { O_CREAT, O_EXCL, O_WRONLY } = constants; + const handle = await this.#system.open( + path, + O_CREAT | O_EXCL | O_WRONLY, + 0o600, + ); + // Created by the storage: removed by its cleanup + this.#handle = handle; + this.#temp = path; + this.#path = path; + if (this.#stopped) { + throw new Error("The storage has been cleaned up"); + } + const output = handle.createWriteStream(); + this.#output = output; + await pipeline(stream, output); + await handle.close(); + this.#size = output.bytesWritten; + this.#truncated = stream.truncated === true; + } + + /** + * Moves the file: a hard link (which fails on an existing target), or a + * copy that never replaces one to another file system. A copy that fails + * removes what it has written. + */ + async #move(source: string, target: string): Promise { + try { + await this.#system.link(source, target); + return; + } catch (error: unknown) { + if (codeOf(error) !== "EXDEV") { + throw error; + } + } + try { + await this.#system.copyFile(source, target, constants.COPYFILE_EXCL); + } catch (error: unknown) { + if (codeOf(error) !== "EEXIST") { + await unlinkIfExists(this.#system, target).catch(ignore); + } + throw error; + } + } + + /** Runs `fn` once the operations started before it have settled */ + #exclusive(fn: () => Promise): Promise { + const run = this.#lock.then(fn); + this.#lock = run.catch(ignore); + return run; + } +} + +/** + * The files of a request on the disk, in a private directory (`0700`) that + * is created with the first one, and removed by the cleanup of the request + * with every file that has not been persisted + */ +export class DiskStore { + readonly #system: IFileSystem; + readonly #directory: Promise; + readonly #files: StoredFile[] = []; + #cleaned = false; + + /** + * Registers the cleanup (with `register`, e.g. `context.onCleanup`) and + * then creates the directory under `root` + */ + public constructor( + root: string, + register: (cleanup: () => Promise) => void, + system: IFileSystem = fs, + ) { + this.#system = system; + register(() => this.cleanup()); + this.#directory = system.mkdtemp(join(root, "binden-multipart-")); + // Observed: the writes report it + this.#directory.catch(ignore); + } + + /** + * Writes a file into the directory (see `StoredFile.written`): none once + * the cleanup has started, which would leave it behind + */ + public add(info: IFileInfo, stream: IFileStream): StoredFile { + if (this.#cleaned) { + throw new Error("The storage has been cleaned up"); + } + const file = new StoredFile(info, this.#system, this.#directory, stream); + this.#files.push(file); + return file; + } + + /** + * Stops the writes and waits for them, closes the descriptors, removes the + * files that have not been persisted, then the directory. Every step is + * tried: their failures are thrown together. + */ + public async cleanup(): Promise { + this.#cleaned = true; + const errors: unknown[] = []; + for (const file of this.#files) { + file.stop(); + } + await Promise.allSettled(this.#files.map(({ written }) => written)); + for (const file of this.#files) { + await file.close().catch((error: unknown) => errors.push(error)); + } + for (const file of this.#files) { + await file.remove().catch((error: unknown) => errors.push(error)); + } + const directory = await this.#directory.catch(() => null); + if (directory !== null) { + await this.#system + .rmdir(directory) + .catch((error: unknown) => errors.push(error)); + } + if (errors.length > 0) { + throw new AggregateError(errors, "The files have not all been removed"); + } + } +} diff --git a/packages/multipart/src/multipart.ts b/packages/multipart/src/multipart.ts index eaf27be2..1afd900e 100644 --- a/packages/multipart/src/multipart.ts +++ b/packages/multipart/src/multipart.ts @@ -1,4 +1,11 @@ import { BindenError, type Context, Middleware } from "binden"; +import { + DiskStore, + type IFileStream, + type IStoredFile, + type IStoredForm, + StoredFile, +} from "./disk.js"; import { type IFileFilter, type IFileInfo, @@ -21,17 +28,15 @@ import { MultipartFile } from "./file.js"; import type { Readable } from "node:stream"; import { filenameOf } from "./filename.js"; import { race } from "./race.js"; +import { tmpdir } from "node:os"; /** The logger of a context */ type ILog = Context["log"]; -/** A file stream of busboy */ -type IFileStream = Readable & { truncated?: boolean }; - /** A part, in the order of the request (a file once it has been read) */ interface ISlot { readonly name: string; - value: MultipartFile | string | null; + value: IStoredFile | MultipartFile | string | null; } /** The methods whose requests have no body to parse */ @@ -102,6 +107,7 @@ class Parser { readonly #streams = new Set(); readonly #detach: (() => void)[] = []; #busboy: Busboy | null = null; + #store: DiskStore | null = null; #over = false; public constructor( @@ -291,6 +297,9 @@ class Parser { } else if (this.#mode.kind === "stream") { await this.#stream(this.#mode.on_file, info, stream); return; + } else if (this.#mode.kind === "disk") { + slot.value = await this.#disk(this.#mode.dir, info, stream); + return; } const chunks = await collect(stream); slot.value = new MultipartFile(chunks, info.filename, { @@ -320,6 +329,23 @@ class Parser { } } + /** + * Stores a file on the disk, in the private directory of the request + * (created with its first file, and removed with its cleanup) + */ + async #disk( + dir: string | null, + info: IFileInfo, + stream: IFileStream, + ): Promise { + this.#store ??= new DiskStore(dir ?? tmpdir(), (cleanup) => { + this.#context.onCleanup(cleanup); + }); + const file = this.#store.add(info, stream); + await file.written; + return file; + } + /** Whether `file_filter` accepts a file (awaited until the parse is over) */ async #accepts(info: IFileInfo): Promise { const filter = this.#file_filter; @@ -414,14 +440,34 @@ class Parser { return; } this.#end(); + this.#context.request.body = + this.#mode.kind === "disk" ? this.#stored() : this.#form(); + this.#deferred.resolve(null); + } + + /** The fields and the files in memory (skipped ones excluded) */ + #form(): FormData { const form = new FormData(); for (const { name, value } of this.#slots) { - if (value !== null) { + if (typeof value === "string" || value instanceof MultipartFile) { form.append(name, value); } } - this.#context.request.body = form; - this.#deferred.resolve(null); + return form; + } + + /** The fields and the stored files (skipped ones excluded) */ + #stored(): IStoredForm { + const fields = new Map(); + const files = new Map(); + for (const { name, value } of this.#slots) { + if (typeof value === "string") { + fields.set(name, [...(fields.get(name) ?? []), value]); + } else if (value instanceof StoredFile) { + files.set(name, [...(files.get(name) ?? []), value]); + } + } + return { fields, files }; } #end(): void { diff --git a/packages/multipart/src/options.ts b/packages/multipart/src/options.ts index 898d04df..fcdd87bc 100644 --- a/packages/multipart/src/options.ts +++ b/packages/multipart/src/options.ts @@ -1,6 +1,7 @@ import type { BusboyConfig, Limits } from "busboy"; import type { Context, IMiddlewareParams } from "binden"; import type { Readable } from "node:stream"; +import { resolve } from "node:path"; /** * The configuration of busboy: its `limits`, the charsets of the parts and @@ -60,11 +61,24 @@ export interface IStreamStorage { on_file(file: IStreamFile, context: Context): unknown; } -/** Where the files go: kept in memory (`"memory"`), or streamed to a handler */ -export type IStorage = "memory" | IStreamStorage; +/** + * Stores each file on the disk (see `IStoredFile`): `request.body` is an + * `IStoredForm` + */ +export interface IDiskStorage { + /** The directory of the private directories of the requests (`os.tmpdir()`) */ + dir?: string | undefined; +} + +/** + * Where the files go: in memory (`"memory"`), on the disk (`{ dir }`), or to + * a handler (`{ on_file }`) + */ +export type IStorage = "memory" | IDiskStorage | IStreamStorage; /** The storage of a parse */ export type IMode = + | { readonly kind: "disk"; readonly dir: string | null } | { readonly kind: "memory" } | { readonly kind: "stream"; readonly on_file: IStreamStorage["on_file"] }; @@ -96,7 +110,7 @@ export interface IMultipartOptions extends IMiddlewareParams { filtered?: "reject" | "skip" | undefined; /** * Where the files go: in memory, as a `MultipartFile` of the `FormData` - * (`"memory"`), or to a handler (`{ on_file }`) + * (`"memory"`), on the disk (`{ dir }`), or to a handler (`{ on_file }`) */ storage?: IStorage | undefined; } @@ -125,7 +139,7 @@ const optionNames: ReadonlySet = new Set([ "storage", ]); -const storageNames: ReadonlySet = new Set(["on_file"]); +const storageNames: ReadonlySet = new Set(["dir", "on_file"]); const configNames: ReadonlySet = new Set([ "headers", @@ -269,16 +283,26 @@ function checkConfig(value: unknown): IMultipartConfig { }; } -/** The storage of the files */ +/** The storage of the files: its directory is resolved now */ function checkStorage(value: unknown): IMode { if (value === "memory") { return { kind: "memory" }; } - const { on_file } = checkObject(value, "storage", storageNames); - if (typeof on_file !== "function") { - throw new TypeError("`storage.on_file` is not a function"); + const storage = checkObject(value, "storage", storageNames); + const { dir, on_file } = storage; + if ("on_file" in storage) { + if ("dir" in storage) { + throw new TypeError("`storage.dir` and `storage.on_file` are exclusive"); + } else if (typeof on_file !== "function") { + throw new TypeError("`storage.on_file` is not a function"); + } + return { kind: "stream", on_file: on_file as IStreamStorage["on_file"] }; + } else if (typeof dir === "undefined") { + return { kind: "disk", dir: null }; + } else if (typeof dir !== "string" || dir === "") { + throw new TypeError("`storage.dir` is not a non-empty string"); } - return { kind: "stream", on_file: on_file as IStreamStorage["on_file"] }; + return { kind: "disk", dir: resolve(dir) }; } /** diff --git a/packages/multipart/test/disk-store.test.ts b/packages/multipart/test/disk-store.test.ts new file mode 100644 index 00000000..9b9cdd82 --- /dev/null +++ b/packages/multipart/test/disk-store.test.ts @@ -0,0 +1,346 @@ +import { DiskStore, type IFileSystem } from "../src/disk.js"; +import { PassThrough, Readable } from "node:stream"; +import { type TestContext, describe, it } from "node:test"; +import { deepEqual, equal, ok, rejects, throws } from "node:assert/strict"; +import { eventually, sleep, within } from "./helpers.js"; +import fs, { + mkdtemp, + readFile, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { constants } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +/** The file system, with some of its operations replaced */ +function system(overrides: Partial = {}): IFileSystem { + return { + mkdtemp: (prefix) => fs.mkdtemp(prefix), + open: (path, flags, mode) => fs.open(path, flags, mode), + link: (existing, target) => fs.link(existing, target), + copyFile: (source, target, mode) => fs.copyFile(source, target, mode), + unlink: (path) => fs.unlink(path), + rmdir: (path) => fs.rmdir(path), + ...overrides, + }; +} + +/** An error of the system */ +function failure(code: string): NodeJS.ErrnoException { + return Object.assign(new Error(code), { code }); +} + +/** A store of a test, and its directory */ +interface ITestStore { + readonly root: string; + readonly store: DiskStore; +} + +/** A store under a directory of the test (removed after it) */ +async function create( + t: TestContext, + overrides: Partial = {}, +): Promise { + const root = await mkdtemp(join(tmpdir(), "binden-multipart-test-")); + t.after(() => rm(root, { recursive: true, force: true })); + const store = new DiskStore( + root, + () => { + // Run by the tests (`store.cleanup()`) + }, + system(overrides), + ); + return { root, store }; +} + +const info = { + field: "file", + filename: "file.txt", + mime_type: "text/plain", + encoding: "7bit", +}; + +/** A stream of a content */ +function content(text: string): Readable { + return Readable.from([Buffer.from(text)]); +} + +describe("DiskStore", () => { + it("registers its cleanup, then opens new private files in a directory of its own", async (t) => { + const calls: unknown[] = []; + const root = await mkdtemp(join(tmpdir(), "binden-multipart-test-")); + t.after(() => rm(root, { recursive: true, force: true })); + let cleanup = (): Promise => Promise.resolve(); + const store = new DiskStore( + root, + (registered) => { + calls.push("register"); + cleanup = registered; + }, + system({ + mkdtemp: (prefix) => { + calls.push(["mkdtemp", prefix]); + return fs.mkdtemp(prefix); + }, + open: (path, flags, mode) => { + calls.push(["open", flags, mode]); + return fs.open(path, flags, mode); + }, + }), + ); + + const file = store.add(info, content("Hello")); + await file.written; + + const { O_CREAT, O_EXCL, O_WRONLY } = constants; + deepEqual(calls, [ + "register", + ["mkdtemp", join(root, "binden-multipart-")], + ["open", O_CREAT | O_EXCL | O_WRONLY, 0o600], + ]); + deepEqual( + [file.size, file.truncated, await readFile(file.path, "utf8")], + [5, false, "Hello"], + ); + await cleanup(); + deepEqual(await readdir(root), []); + }); + + it("persists a file once it is written", async (t) => { + const { root, store } = await create(t); + const stream = new PassThrough(); + const target = join(root, "kept.txt"); + + const file = store.add(info, stream); + const persisted = file.persist(target); + stream.write("Hel"); + await sleep(10); + stream.end("lo"); + await within(persisted); + + deepEqual([file.path, await readFile(target, "utf8")], [target, "Hello"]); + }); + + it("copies a file to another file system", async (t) => { + const { root, store } = await create(t, { + link: () => Promise.reject(failure("EXDEV")), + }); + const target = join(root, "kept.txt"); + + const file = store.add(info, content("Hello")); + await file.written; + const temp = file.path; + await file.persist(target); + await store.cleanup(); + + deepEqual(await readdir(root), ["kept.txt"]); + equal(await readFile(target, "utf8"), "Hello"); + ok(temp !== target); + }); + + it("removes what a failed copy has written, and keeps the file", async (t) => { + let copies = 0; + const { root, store } = await create(t, { + link: () => Promise.reject(failure("EXDEV")), + copyFile: async (source, target, mode) => { + copies += 1; + if (copies === 1) { + await writeFile(target, "Hel"); + throw failure("ENOSPC"); + } + await fs.copyFile(source, target, mode); + }, + }); + const target = join(root, "kept.txt"); + + const file = store.add(info, content("Hello")); + await rejects(file.persist(target), failure("ENOSPC")); + deepEqual((await readdir(root)).includes("kept.txt"), false); + await file.persist(target); + + equal(await readFile(target, "utf8"), "Hello"); + }); + + it("never removes the existing target of a copy", async (t) => { + const { root, store } = await create(t, { + link: () => Promise.reject(failure("EXDEV")), + }); + const target = join(root, "existing.txt"); + await writeFile(target, "Existing"); + + const file = store.add(info, content("Hello")); + await rejects(file.persist(target), { code: "EEXIST" }); + + equal(await readFile(target, "utf8"), "Existing"); + }); + + it("throws the error of a copy, also when what it has written cannot be removed", async (t) => { + const { root, store } = await create(t, { + link: () => Promise.reject(failure("EXDEV")), + copyFile: () => Promise.reject(failure("ENOSPC")), + unlink: async (path) => { + if (path.endsWith("kept.txt")) { + throw failure("EACCES"); + } + await fs.unlink(path); + }, + }); + + const file = store.add(info, content("Hello")); + + await rejects(file.persist(join(root, "kept.txt")), failure("ENOSPC")); + }); + + it("leaves the temporary file of a persisted file that cannot be removed to the cleanup", async (t) => { + let failed = false; + const { root, store } = await create(t, { + unlink: async (path) => { + if (!failed) { + failed = true; + throw failure("EBUSY"); + } + await fs.unlink(path); + }, + }); + const target = join(root, "kept.txt"); + + const file = store.add(info, content("Hello")); + await file.written; + const temp = file.path; + await file.persist(target); + const left = await readFile(temp, "utf8"); + // A hard link: the same file + const linked = (await stat(temp)).ino === (await stat(target)).ino; + await store.cleanup(); + + deepEqual([file.path, left, linked], [target, "Hello", true]); + deepEqual(await readdir(root), ["kept.txt"]); + }); + + it("waits for a file being persisted before it removes the files", async (t) => { + const { promise: gate, resolve: open } = Promise.withResolvers(); + const { root, store } = await create(t, { + link: async (existing, target) => { + await gate; + await fs.link(existing, target); + }, + }); + const target = join(root, "kept.txt"); + + const file = store.add(info, content("Hello")); + await file.written; + const persisted = file.persist(target); + await sleep(10); + const cleaned = store.cleanup(); + await sleep(10); + open(null); + await within(Promise.all([persisted, cleaned])); + + deepEqual(await readdir(root), ["kept.txt"]); + equal(await readFile(target, "utf8"), "Hello"); + }); + + it("does not persist nor add a file once it has been cleaned up", async (t) => { + const { root, store } = await create(t); + + const file = store.add(info, content("Hello")); + await file.written; + await store.cleanup(); + + await rejects( + file.persist(join(root, "kept.txt")), + new Error("The file has been removed"), + ); + throws( + () => store.add(info, content("Hello")), + new Error("The storage has been cleaned up"), + ); + deepEqual(await readdir(root), []); + }); + + it("does not persist a file that has not been written", async (t) => { + const { root, store } = await create(t); + const stream = new PassThrough(); + + // Observed, like the streams of busboy + stream.on("error", () => { + // Reported by the write + }); + const file = store.add(info, stream); + stream.destroy(new Error("A failure")); + + await rejects(file.persist(join(root, "kept.txt")), new Error("A failure")); + }); + + it("reports every failure of its cleanup", async (t) => { + const { store } = await create(t, { + open: async (path, flags, mode) => { + const handle = await fs.open(path, flags, mode); + const close = handle.close.bind(handle); + handle.close = async (): Promise => { + await close(); + throw failure("EIO"); + }; + return handle; + }, + unlink: () => Promise.reject(failure("EACCES")), + rmdir: () => Promise.reject(failure("ENOTEMPTY")), + }); + + const file = store.add(info, content("Hello")); + await rejects(file.written, failure("EIO")); + const error = await store.cleanup().catch((thrown: unknown) => thrown); + + ok(error instanceof AggregateError); + deepEqual( + error.errors.map((each: NodeJS.ErrnoException) => each.code), + ["EIO", "EACCES", "ENOTEMPTY"], + ); + }); + + it("stops the writes that are opening a file", async (t) => { + const { promise: gate, resolve: open } = Promise.withResolvers(); + const { root, store } = await create(t, { + open: async (path, flags, mode) => { + await gate; + return fs.open(path, flags, mode); + }, + }); + + const file = store.add(info, content("Hello")); + await eventually(async () => (await readdir(root)).length === 1); + const cleaned = store.cleanup(); + open(null); + + await rejects(file.written, new Error("The storage has been cleaned up")); + await within(cleaned); + deepEqual(await readdir(root), []); + }); + + it("stops the writes in progress", async (t) => { + const { root, store } = await create(t); + const stream = new PassThrough(); + + const file = store.add(info, stream); + stream.write("Hel"); + await eventually(() => file.path !== ""); + await within(store.cleanup()); + + await rejects(file.written); + deepEqual(await readdir(root), []); + }); + + it("removes nothing when its directory cannot be created", async (t) => { + const { store } = await create(t, { + mkdtemp: () => Promise.reject(failure("ENOENT")), + }); + + const file = store.add(info, content("Hello")); + + await rejects(file.written, failure("ENOENT")); + await within(store.cleanup()); + }); +}); diff --git a/packages/multipart/test/disk.test.ts b/packages/multipart/test/disk.test.ts new file mode 100644 index 00000000..a51455e7 --- /dev/null +++ b/packages/multipart/test/disk.test.ts @@ -0,0 +1,419 @@ +import { BindenError, type Context } from "binden"; +import { type TestContext, describe, it } from "node:test"; +import { basename, dirname, join } from "node:path"; +import { + body, + eventually, + part, + post, + serve, + sleep, + type, + within, +} from "./helpers.js"; +import { deepEqual, equal, ok } from "node:assert/strict"; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import multipart, { type IStoredFile, type IStoredForm } from "../index.js"; +import { existsSync } from "node:fs"; +import { request as httpRequest } from "node:http"; +import { tmpdir } from "node:os"; + +const uuid = + /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/u; +const posix = process.platform !== "win32"; + +/** A directory of the test, removed after it */ +async function directory(t: TestContext): Promise { + const dir = await mkdtemp(join(tmpdir(), "binden-multipart-test-")); + t.after(() => rm(dir, { recursive: true, force: true })); + return dir; +} + +/** The stored files of the form, in their order */ +function filesOf(context: Context): IStoredFile[] { + return [...(context.request.body as IStoredForm).files.values()].flat(); +} + +/** A form of two fields and two files */ +const form = body( + part('name="a"', "1"), + part('name="file"; filename="../../etc/passwd"', "Hello"), + part('name="a"', "2"), + part('name="file"; filename="b.png"', "World", ["Content-Type: image/png"]), +); + +describe("Multipart (disk storage)", () => { + it("stores the files in a private directory, which is removed after the response", async (t) => { + const seen: unknown[] = []; + const paths: string[] = []; + const { url } = await serve(t, [ + multipart({ storage: {} }), + async (context: Context): Promise => { + const { fields, files } = context.request.body as IStoredForm; + seen.push([...fields], [...files.keys()]); + for (const file of filesOf(context)) { + const { field, filename, mime_type, encoding, size, truncated } = + file; + const dir = dirname(file.path); + paths.push(file.path, dir); + seen.push([ + field, + filename, + mime_type, + encoding, + size, + truncated, + await readFile(file.path, "utf8"), + uuid.test(basename(file.path)), + dir.startsWith(join(tmpdir(), "binden-multipart-")), + posix ? (await stat(dir)).mode & 0o777 : 0o700, + posix ? (await stat(file.path)).mode & 0o777 : 0o600, + ]); + } + await context.send(); + }, + ]); + + const reply = await post(url, form); + + equal(reply.status, 200); + deepEqual(seen, [ + [["a", ["1", "2"]]], + ["file"], + [ + "file", + "passwd", + "text/plain", + "7bit", + 5, + false, + "Hello", + true, + true, + 0o700, + 0o600, + ], + [ + "file", + "b.png", + "image/png", + "7bit", + 5, + false, + "World", + true, + true, + 0o700, + 0o600, + ], + ]); + await eventually(() => paths.every((path) => !existsSync(path))); + }); + + it("stores the files under `storage.dir`", async (t) => { + const dir = await directory(t); + const parents: string[] = []; + const { url } = await serve(t, [ + multipart({ storage: { dir } }), + async (context: Context): Promise => { + for (const file of filesOf(context)) { + parents.push(dirname(dirname(file.path))); + } + await context.send(); + }, + ]); + + const reply = await post(url, form); + + equal(reply.status, 200); + deepEqual(parents, [dir, dir]); + await eventually(async () => (await readdir(dir)).length === 0); + }); + + it("resolves a relative `storage.dir` when the middleware is created", async (t) => { + const dir = await directory(t); + const cwd = process.cwd(); + process.chdir(dir); + const parser = multipart({ storage: { dir: "uploads" } }); + const uploads = join(process.cwd(), "uploads"); + process.chdir(cwd); + await mkdir(uploads); + const parents: string[] = []; + const { url } = await serve(t, [ + parser, + async (context: Context): Promise => { + for (const file of filesOf(context)) { + parents.push(dirname(dirname(file.path))); + } + await context.send(); + }, + ]); + + const reply = await post(url, form); + + equal(reply.status, 200); + deepEqual(parents, [uploads, uploads]); + }); + + it("persists the files that are moved to a path of their own", async (t) => { + const dir = await directory(t); + const target = join(dir, "kept.txt"); + const outcomes: unknown[] = []; + let temp = ""; + const { url } = await serve(t, [ + multipart({ storage: {} }), + async (context: Context): Promise => { + const [file] = filesOf(context); + temp = file.path; + await file.persist(target); + await file.persist(join(dir, ".", "kept.txt")); + await file + .persist(join(dir, "other.txt")) + .catch((error: unknown) => outcomes.push((error as Error).message)); + outcomes.push(file.path, existsSync(temp)); + await context.send(); + }, + ]); + + const reply = await post(url, form); + + equal(reply.status, 200); + deepEqual(outcomes, [ + "The file has already been persisted elsewhere", + target, + false, + ]); + await eventually(() => !existsSync(dirname(temp))); + equal(await readFile(target, "utf8"), "Hello"); + deepEqual(await readdir(dir), ["kept.txt"]); + }); + + it("never replaces an existing file or link when it persists a file", async (t) => { + const dir = await directory(t); + const existing = join(dir, "existing.txt"); + const link = join(dir, "link.txt"); + await writeFile(existing, "Existing"); + await symlink(join(dir, "missing.txt"), link); + const codes: unknown[] = []; + const { url } = await serve(t, [ + multipart({ storage: {} }), + async (context: Context): Promise => { + const [file] = filesOf(context); + for (const target of [existing, link, join(dir, "none", "a.txt")]) { + await file.persist(target).catch((error: unknown) => { + codes.push((error as NodeJS.ErrnoException).code); + }); + } + // Still owned by the storage: it can be persisted elsewhere + await file.persist(join(dir, "kept.txt")); + await context.send(); + }, + ]); + + const reply = await post(url, form); + + equal(reply.status, 200); + deepEqual(codes, ["EEXIST", "EEXIST", "ENOENT"]); + equal(await readFile(existing, "utf8"), "Existing"); + equal(await readFile(join(dir, "kept.txt"), "utf8"), "Hello"); + }); + + it("refuses to persist a file once the response is closed", async (t) => { + const { promise: outcome, resolve } = Promise.withResolvers(); + const { url } = await serve(t, [ + multipart({ storage: {} }), + async (context: Context): Promise => { + const [file] = filesOf(context); + context.response.once("close", () => { + setTimeout(() => { + file.persist(join(tmpdir(), "binden-never")).then(resolve, resolve); + }, 50); + }); + await context.send(); + }, + ]); + + equal((await post(url, form)).status, 200); + const error = await within(outcome); + + ok(error instanceof Error); + equal(error.message, "The file has been removed"); + }); + + it("keeps a persisted file when the client goes away", async (t) => { + const dir = await directory(t); + const target = join(dir, "kept.txt"); + const { promise: persisted, resolve } = Promise.withResolvers(); + const { url } = await serve(t, [ + multipart({ storage: {} }), + async (context: Context): Promise => { + const [file] = filesOf(context); + await file.persist(target); + resolve(file.path); + // Never replies: the client goes away + await new Promise((settle) => { + context.signal.addEventListener("abort", settle); + }); + }, + ]); + + const client = httpRequest(url, { + method: "POST", + headers: { "Content-Type": type }, + }); + client.on("error", () => { + // The connection is closed on purpose + }); + client.end(form); + await within(persisted); + client.destroy(); + await sleep(50); + + equal(await readFile(target, "utf8"), "Hello"); + }); + + it("tells the size and the truncation of the files (`throw_limits: false`)", async (t) => { + const seen: unknown[] = []; + const { url } = await serve(t, [ + multipart({ + storage: {}, + throw_limits: false, + config: { limits: { fileSize: 3 } }, + }), + async (context: Context): Promise => { + for (const { size, truncated, path } of filesOf(context)) { + seen.push([size, truncated, await readFile(path, "utf8")]); + } + await context.send(); + }, + ]); + + const reply = await post( + url, + body( + part('name="a"; filename="a.txt"', "Hello"), + part('name="b"; filename="b.txt"', "Hi"), + ), + ); + + equal(reply.status, 200); + deepEqual(seen, [ + [3, true, "Hel"], + [2, false, "Hi"], + ]); + }); + + it("does not store the skipped files", async (t) => { + const dir = await directory(t); + const seen: unknown[] = []; + const { url } = await serve(t, [ + multipart({ + storage: { dir }, + filtered: "skip", + file_filter: ({ mime_type }) => mime_type === "image/png", + }), + async (context: Context): Promise => { + const files = filesOf(context); + seen.push(files.map(({ filename }) => filename)); + const [file] = files; + seen.push((await readdir(dirname(file.path))).length); + await context.send(); + }, + ]); + + const reply = await post(url, form); + + equal(reply.status, 200); + deepEqual(seen, [["b.png"], 1]); + await eventually(async () => (await readdir(dir)).length === 0); + }); + + it("removes the files of a refused or aborted request", async (t) => { + const dir = await directory(t); + const { url } = await serve(t, [ + multipart({ storage: { dir }, limit: 1 << 16 }), + ]); + const data = body( + part('name="a"; filename="a.txt"', "a"), + part('name="b"; filename="b.bin"', "b".repeat(1 << 17)), + ); + + // Refused by `limit` in the middle of the second file + const reply = await new Promise((resolve, reject) => { + const client = httpRequest(url, { + method: "POST", + headers: { "Content-Type": type, "Transfer-Encoding": "chunked" }, + signal: AbortSignal.timeout(5000), + }); + client + .once("response", (response) => { + response.resume(); + resolve(response.statusCode); + client.destroy(); + }) + .once("error", reject) + .write(data.slice(0, 2048)); + setTimeout(() => { + client.write(data.slice(2048)); + }, 50); + }); + equal(reply, 413); + await eventually(async () => (await readdir(dir)).length === 0); + + // Aborted by the client in the middle of the second file + const client = httpRequest(url, { + method: "POST", + headers: { "Content-Type": type, "Transfer-Encoding": "chunked" }, + }); + client.on("error", () => { + // The connection is closed on purpose + }); + client.write(data.slice(0, 2048)); + await eventually(async () => (await readdir(dir)).length === 1); + await sleep(20); + client.destroy(); + await eventually(async () => (await readdir(dir)).length === 0); + }); + + it("replies `500` when the files cannot be stored", async (t) => { + const dir = await directory(t); + const missing = join(dir, "missing"); + const { url } = await serve(t, [multipart({ storage: { dir: missing } })]); + + const reply = await post(url, form); + + equal(reply.status, 500); + equal(existsSync(missing), false); + }); + + it("passes the errors of `persist()` to the application", async (t) => { + const errors: unknown[] = []; + const { url } = await serve(t, [ + multipart({ storage: {} }), + async (context: Context): Promise => { + const [file] = filesOf(context); + for (const target of ["", 1]) { + await file + .persist(target as string) + .catch((error: unknown) => errors.push(error)); + } + throw new BindenError(409); + }, + ]); + + equal((await post(url, form)).status, 409); + deepEqual(errors, [ + new TypeError("`target` is not a non-empty string"), + new TypeError("`target` is not a non-empty string"), + ]); + }); +}); diff --git a/packages/multipart/test/helpers.ts b/packages/multipart/test/helpers.ts index 692af65a..94c02c8c 100644 --- a/packages/multipart/test/helpers.ts +++ b/packages/multipart/test/helpers.ts @@ -78,6 +78,20 @@ export async function within(promise: Promise, ms = 5000): Promise { } } +/** Resolves once `check()` is true, which it must be within `ms` milliseconds */ +export async function eventually( + check: () => Promise | boolean, + ms = 2000, +): Promise { + const deadline = Date.now() + ms; + while (!(await check())) { + if (Date.now() > deadline) { + throw new Error(`Not true within ${ms} ms`); + } + await sleep(5); + } +} + /** A boundary for the bodies of the tests */ export const boundary = "binden-boundary"; diff --git a/packages/multipart/test/stream.test.ts b/packages/multipart/test/stream.test.ts index c0a63d4d..9f7a4615 100644 --- a/packages/multipart/test/stream.test.ts +++ b/packages/multipart/test/stream.test.ts @@ -304,10 +304,16 @@ describe("Multipart (stream storage)", () => { it("refuses an invalid `storage`", () => { const invalid: [unknown, Error][] = [ ["disk", new TypeError("`storage` is not an object")], - [{}, new TypeError("`storage.on_file` is not a function")], + [{ on_file: null }, new TypeError("`storage.on_file` is not a function")], [ { on_file: (): null => null, dir: "/tmp" }, - new TypeError("Unknown option: `storage.dir`"), + new TypeError("`storage.dir` and `storage.on_file` are exclusive"), + ], + [{ dir: "" }, new TypeError("`storage.dir` is not a non-empty string")], + [{ dir: 1 }, new TypeError("`storage.dir` is not a non-empty string")], + [ + { directory: "/tmp" }, + new TypeError("Unknown option: `storage.directory`"), ], ]; for (const [storage, error] of invalid) { From 6b768fe57c95cddd57ba30978d31e6a5a9b4ccce Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 02:13:51 +0300 Subject: [PATCH 164/675] docs: describe the storages, limits and errors of multipart The README described the memory storage only, with an example of `fieldNameSize`, a limit that busboy applies to urlencoded bodies only, so that it had no effect, and no word on what a limit or a malformed body replied. It now describes the three storages (memory, disk with `persist()`, and stream), the file filter (which checks the declared metadata only, never the content), the names of the files, `multipartInfo()` for the truncated forms, and the replies to every error, including how a refused request keeps its connection until the reply. It recommends explicit limits for production (`limit` 10 MiB, `fileSize` 5 MiB, `files` 10, `fields` 100, `parts` 1000) and tells how busboy counts them. Its examples type check. Co-Authored-By: Claude Opus 5.5 --- packages/multipart/README.md | 216 ++++++++++++++++++++++------ packages/multipart/src/multipart.ts | 4 +- packages/multipart/src/options.ts | 5 +- 3 files changed, 177 insertions(+), 48 deletions(-) diff --git a/packages/multipart/README.md b/packages/multipart/README.md index 4fbd6594..48528402 100644 --- a/packages/multipart/README.md +++ b/packages/multipart/README.md @@ -2,6 +2,8 @@ [Binden](https://github.com/binden-js/binden) `multipart/form-data` parser middleware (based on [busboy](https://github.com/mscdex/busboy)). +A request of another media type is skipped, and its body is not read: another parser (e.g. [`@binden/body`](https://github.com/binden-js/binden/tree/main/packages/body)) can take it. A request that the middleware has already parsed is skipped too. + ## Installation ```bash @@ -10,75 +12,203 @@ npm install @binden/multipart ## Usage +The fields and the files are parsed into a `FormData` (`request.body`), in the order of the request. A file is a `MultipartFile`: a `File` that tells whether its content has been truncated. + ```typescript import { type Context, Router } from "binden"; -import multipart from "@binden/multipart"; +import multipart, { MultipartFile } from "@binden/multipart"; -const formDataParser = multipart({ - config: { limits: { fields: 2, fileSize: 1024 } }, +const MiB = 1024 * 1024; +const formData = multipart({ + limit: 10 * MiB, + config: { + limits: { fileSize: 5 * MiB, files: 10, fields: 100, parts: 1000 }, + }, }); -const next = (context: Context): Promise => { - if (context.request.body instanceof FormData) { - context.log.info("Body has been parsed successfully"); - return context.status(200).text("GOOD"); - } else { - context.log.warn("Body is not a valid `form-data`"); - return context.status(400).text("BAD"); +const upload = async (context: Context): Promise => { + const form = context.request.body as FormData; + const avatar = form.get("avatar"); + if (!(avatar instanceof MultipartFile)) { + return context.throw(400, { expose: true, message: "No avatar" }); } + await context.json({ name: avatar.name, size: avatar.size }); }; -app.use("/formdata", new Router().post(formDataParser, next)); +app.use("/upload", new Router().post(formData, upload)); ``` -### Limits +`multipart(options)` is the same as `new Multipart(options)`. Invalid options (e.g. an unknown option, also in `config` and `config.limits`, or a negative limit) throw a `TypeError` or a `RangeError` when the middleware is created. -Fields and files are read into memory, and nothing limits them by default (`busboy` limits only -the size of a field, to 1 MiB). Set the `busboy` [limits](https://github.com/mscdex/busboy/#api) -of the files (`fileSize`, `files`, `parts`), and `limit`, the largest body in bytes (all of its -parts included): a larger one gets `413 Content Too Large`, and is not read further (the -connection is closed after the reply). +It works with the body parser as a shared middleware: each one parses its own media types. ```typescript +import { BodyParser } from "@binden/body"; import { Multipart } from "@binden/multipart"; -const formDataParser = new Multipart({ - limit: 20 * 1024 * 1024, - config: { limits: { fileSize: 10 * 1024 * 1024, files: 10, parts: 1000 } }, -}); +app.use(new BodyParser(), new Multipart({ storage: {} })); ``` -### Truncated `FormData` +### Limits + +Nothing limits a request by default but the `fieldSize` of busboy (1 MiB), and a file in memory takes as much memory as it is large: set the limits of your application explicitly. As a starting point for production: -Set the `throw_limits` to `false` to avoid throwing errors. `FormData` might be truncated due to `config.limits` (See `busboy` [limits](https://github.com/mscdex/busboy/#api)) +| Option | What it limits | Suggestion | +| ------------------------- | -------------------------------------------- | ---------- | +| `limit` | The whole body, in bytes, all parts included | 10 MiB | +| `config.limits.fileSize` | A file, in bytes | 5 MiB | +| `config.limits.files` | The count of the files | 10 | +| `config.limits.fields` | The count of the fields | 100 | +| `config.limits.parts` | The count of the parts (fields and files) | 1000 | +| `config.limits.fieldSize` | A field, in bytes | 1 MiB | + +`Infinity` disables a limit. A request over a limit gets a `413 Content Too Large`: its body is not read further, and its connection is closed after the reply (`Connection: close`). A `Content-Length` over `limit` is refused before the body is read; a body without one is counted as it arrives. + +The limits of `config.limits` are those of [busboy](https://github.com/mscdex/busboy/#api), which reports `fileSize`, `fieldSize` and `parts` once they are **reached**: a file of exactly `fileSize` bytes counts as truncated, and a form of exactly `parts` parts as over the limit. Its `fieldNameSize` and `headerPairs` apply to `application/x-www-form-urlencoded` bodies only, which this middleware does not parse: they have no effect here (the headers of a part are at most 16 KiB). + +#### Truncated forms + +With `throw_limits: false` a request that reaches a limit of `config.limits` is not refused: a field is truncated to `fieldSize`, a file to `fileSize` (its `truncated` is `true`), and the parts over `fields`, `files` and `parts` are dropped. `multipartInfo(context)` tells what has been truncated or dropped (it is `null` for a request that the middleware has not parsed). `limit` still refuses a larger body. ```typescript import { type Context, Router } from "binden"; -import { Multipart } from "@binden/multipart"; +import { Multipart, multipartInfo } from "@binden/multipart"; -const formDataParser = new Multipart({ +const formData = new Multipart({ throw_limits: false, - config: { - limits: { - fieldNameSize: 5, - fieldSize: 16 * 1024, - fields: 4, - fileSize: 128 * 1024, - files: 4, - parts: 6, - }, - }, + limit: 1024 * 1024, + config: { limits: { fieldSize: 16 * 1024, fields: 4, files: 4, parts: 8 } }, }); -const next = (context: Context): Promise => { - if (context.request.body instanceof FormData) { - context.log.info("Fields and files might have been truncated"); - return context.status(200).text("MAYBE GOOD"); - } else { - context.log.warn("Body is not a valid `form-data`"); - return context.status(400).text("BAD"); +const next = async (context: Context): Promise => { + const info = multipartInfo(context); + if ( + info !== null && + (info.truncated_fields.length > 0 || info.limits_hit.length > 0) + ) { + context.log.warn("The form has been truncated", { ...info }); } + await context.json({ + fields: [...(context.request.body as FormData).keys()], + }); }; -app.use("/formdata", new Router().post(formDataParser, next)); +app.use("/form", new Router().post(formData, next)); ``` + +### Storage + +`storage` tells where the files go: + +| `storage` | The files | `request.body` | +| -------------------- | ------------------------------ | ---------------------------------------------------------------------- | +| `"memory"` (default) | In memory, as `MultipartFile`s | `FormData` | +| `{ dir }` (or `{}`) | On the disk, as `IStoredFile`s | `{ fields: Map; files: Map }` | +| `{ on_file }` | Streamed to `on_file` | `FormData` of the fields only | + +`run()` settles once the body is parsed and every file has been stored or handled. + +#### Disk + +Each file is written to a file of its own (`0600`, named by a random UUID, opened with `O_EXCL`) in a private directory of the request (`0700`), created under `dir` (resolved when the middleware is created) or `os.tmpdir()`. The name that the client has sent is never a path: it is the `filename` of the file. + +Once the response is closed (also when the request has been refused or aborted), the files that have not been persisted are removed, with the directory. `persist(target)` keeps a file: it moves it to `target` (a hard link, or a copy to another file system), which must not exist: an existing file or symbolic link is an `EEXIST` error, and is never replaced. Persist the files before the response is closed: a removed file cannot be persisted. Persisting a file again to the same path does nothing, to another path throws. A persisted file keeps its mode (`0600`). + +```typescript +import { type Context, Router } from "binden"; +import { type IStoredForm, Multipart } from "@binden/multipart"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; + +const formData = new Multipart({ + storage: { dir: "/var/tmp/uploads" }, + limit: 10 * 1024 * 1024, + config: { limits: { fileSize: 5 * 1024 * 1024, files: 10 } }, +}); + +const keep = async (context: Context): Promise => { + const { fields, files } = context.request.body as IStoredForm; + const kept: string[] = []; + for (const file of files.get("documents") ?? []) { + // A name of the application: the name sent by the client is not trusted + const target = join("/srv/documents", `${randomUUID()}.bin`); + await file.persist(target); + kept.push(target); + } + await context.json({ title: fields.get("title")?.[0] ?? null, kept }); +}; + +app.use("/documents", new Router().post(formData, keep)); +``` + +#### Stream + +`on_file(file, context)` gets each file as a stream (with what it declares, and `truncated()`), e.g. to forward it to a storage service without keeping it: the memory stays bounded, whatever the size of the files. It should read the whole stream before it settles: what it has not read once it has settled is read and discarded (within the limits). Do not destroy the stream: throw a `BindenError` to refuse the request instead (destroying it fails the request). The handlers are awaited in a race with `context.signal`: a handler that never settles does not keep an aborted request, and a later rejection is observed. Each file has its handler, and they may overlap (once the stream of a file is read, the next file may start). + +```typescript +import { BindenError, type Context, Router } from "binden"; +import { type IStreamFile, Multipart } from "@binden/multipart"; +import { createHash } from "node:crypto"; + +const checksums: Record = {}; + +const formData = new Multipart({ + config: { limits: { fileSize: 64 * 1024 * 1024, files: 4 } }, + storage: { + on_file: async (file: IStreamFile): Promise => { + const hash = createHash("sha256"); + for await (const chunk of file.stream) { + hash.update(chunk as Buffer); + } + if (file.truncated()) { + throw new BindenError(413); + } + checksums[file.field] = hash.digest("hex"); + }, + }, +}); + +app.use( + "/checksums", + new Router().post(formData, (context: Context) => context.json(checksums)), +); +``` + +### File filter + +`file_filter(info, context)` decides from what a file declares (`field`, `filename`, `mime_type`, `encoding`) whether it is accepted, before it is read. It may return a `Promise` (awaited until the request is aborted), which must resolve to a boolean: anything else is an error (`500`). A file that it refuses refuses the request with `415 Unsupported Media Type`, or is read and discarded with `filtered: "skip"`. + +The filter checks only the **declared** metadata, never the content: the media type and the name of a file are chosen by the client. Check the content itself (e.g. its magic bytes) when it matters. + +```typescript +import { Multipart } from "@binden/multipart"; + +const images = new Multipart({ + storage: {}, + file_filter: ({ field, mime_type }) => + field === "photo" && ["image/png", "image/jpeg"].includes(mime_type), + filtered: "skip", +}); + +app.use("/photos", images); +``` + +### File names + +The `filename` of a file is the name that the client has sent, without its control characters (also those of the direction of the text), cut to 255 bytes of UTF-8, and `"blob"` when it is empty. The parameters of the parts are decoded as UTF-8 (`config.defParamCharset`). It is metadata: never use it as a path. + +### Errors + +| Situation | Reply | +| -------------------------------------------------------------- | ---------------------------------------------- | +| A limit (`limit`, `config.limits` with `throw_limits`) | `413 Content Too Large` | +| A malformed body, a missing or invalid `boundary` | `400 Bad Request` | +| A file refused by `file_filter`, a field of an unknown charset | `415 Unsupported Media Type` | +| A `BindenError` thrown by `file_filter` or `on_file` | Its status | +| Another failure of `file_filter`, `on_file` or the storage | `500 Internal Server Error` | +| Another media type than `multipart/form-data` | Skipped: the body is not read | +| The request is aborted (see `context.signal`) | `run()` rejects with a `400` of code `aborted` | + +After a refusal the body is not read further, nor parsed, but the connection is kept: the client that is still sending its body receives the status (with `Connection: close`), and the connection is closed after the reply. Only a response that has already started destroys the request. + +The middleware logs with `context.log`, as `{ middleware: "Multipart" }`. diff --git a/packages/multipart/src/multipart.ts b/packages/multipart/src/multipart.ts index 1afd900e..05afd314 100644 --- a/packages/multipart/src/multipart.ts +++ b/packages/multipart/src/multipart.ts @@ -416,9 +416,7 @@ class Parser { request.destroy(); } else { request.pause(); - if (!response.headersSent) { - response.setHeader("Connection", "close"); - } + response.setHeader("Connection", "close"); } // Not while busboy emits the event that has refused the request: it would // go on with a stream that it no longer has diff --git a/packages/multipart/src/options.ts b/packages/multipart/src/options.ts index fcdd87bc..3c47f769 100644 --- a/packages/multipart/src/options.ts +++ b/packages/multipart/src/options.ts @@ -18,8 +18,9 @@ export interface IFileInfo { /** The name of the field */ field: string; /** - * The name of the file, as the client has sent it (see `filenameOf()`): - * metadata, never a path + * The name of the file as the client has sent it, without its control + * characters, cut to 255 bytes, `"blob"` when it is empty: metadata, never + * a path to write to */ filename: string; /** The media type of the part (`Content-Type`, `text/plain` by default) */ From 231942316fd7ddfc10dde1ac8d8dd32bc1bcb9e2 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 02:16:03 +0300 Subject: [PATCH 165/675] docs: document every public member in multipart The constructor of `MultipartFile` and the members of `IStoredForm` had no JSDoc, which the documentation shows as an empty entry (and which TypeDoc reports once `validation.notDocumented` is enabled). Co-Authored-By: Claude Opus 5.5 --- packages/multipart/src/disk.ts | 2 ++ packages/multipart/src/file.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/multipart/src/disk.ts b/packages/multipart/src/disk.ts index c0d4d3a1..f062d9c9 100644 --- a/packages/multipart/src/disk.ts +++ b/packages/multipart/src/disk.ts @@ -29,7 +29,9 @@ export interface IStoredFile extends IFileInfo { /** The body that the disk storage sets: the fields and the stored files, by name, in their order */ export interface IStoredForm { + /** The values of the fields, by name */ readonly fields: Map; + /** The stored files, by the name of their field */ readonly files: Map; } diff --git a/packages/multipart/src/file.ts b/packages/multipart/src/file.ts index 46ed1b11..59c1c98a 100644 --- a/packages/multipart/src/file.ts +++ b/packages/multipart/src/file.ts @@ -13,6 +13,7 @@ export interface IMultipartFileOptions extends NonNullable< export class MultipartFile extends File { readonly #truncated: boolean; + /** A file of the content, the name and the options of a `File`, and `truncated` */ public constructor( bits: ConstructorParameters[0], name: string, From 267aa65db6529e2aab7f59996f590059472388a2 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 01:22:29 +0300 Subject: [PATCH 166/675] fix: leave a response whose headers are sent untouched in @binden/compression `run()` added `Vary: Accept-Encoding` before it checked whether the headers of the response had been sent: a compression that ran once an earlier middleware had sent them threw `ERR_HTTP_HEADERS_SENT`, and the request failed with its connection closed instead of being sent uncompressed. The headers are now checked first, and such a response is left as it is. Co-Authored-By: Claude Opus 5.5 --- packages/compression/index.ts | 8 ++++++- packages/compression/test/compression.test.ts | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/compression/index.ts b/packages/compression/index.ts index 93eee097..acd6eab4 100644 --- a/packages/compression/index.ts +++ b/packages/compression/index.ts @@ -37,13 +37,19 @@ export class Compression extends Middleware { public run(context: Context): void { const { response } = context; + // Sent headers can no longer change (not even `Vary`): the response is + // left as it is + if (response.headersSent) { + return; + } + if (this.format === "auto") { Compression.#varyOnAcceptEncoding(response); } const format = Compression.#getFormat(context, this.format); - if (format === "identity" || response.headersSent) { + if (format === "identity") { return; } diff --git a/packages/compression/test/compression.test.ts b/packages/compression/test/compression.test.ts index 08c87b7e..d651bab6 100644 --- a/packages/compression/test/compression.test.ts +++ b/packages/compression/test/compression.test.ts @@ -829,6 +829,27 @@ describe("Compression", () => { deepStrictEqual((await gunzip(Buffer.from(body))).toString(), "xy"); }); + it("leaves a response whose headers have been sent before it runs", async () => { + const expected = "Hello World"; + app.use( + (ct: Context) => { + ct.response.flushHeaders(); + }, + new Compression({ format: "auto" }), + (ct: Context) => ct.send(expected), + ); + + const response = await request(url, { + headers: { "Accept-Encoding": "gzip" }, + signal: AbortSignal.timeout(5000), + }); + + deepStrictEqual(response.statusCode, 200); + ok(typeof response.headers["content-encoding"] === "undefined"); + ok(typeof response.headers.vary === "undefined"); + deepStrictEqual(await response.body.text(), expected); + }); + it("does not compress when the headers have already been sent", async () => { const expected = "Hello World"; app.use(new Compression({ format: "gzip" }), (ct: Context) => { From 22b9397738191f8168a9d71248ac0d0b1d472ac1 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 01:23:00 +0300 Subject: [PATCH 167/675] refactor!: rename IComressFormats to ICompressFormats in @binden/compression The exported type of the `format` option was misspelled. It is renamed before the major release, rather than kept with an alias forever. BREAKING CHANGE: import the type `ICompressFormats` instead of `IComressFormats`. Co-Authored-By: Claude Opus 5.5 --- packages/compression/index.ts | 15 ++++++++------- packages/compression/test/compression.test.ts | 4 ++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/compression/index.ts b/packages/compression/index.ts index acd6eab4..f96b4366 100644 --- a/packages/compression/index.ts +++ b/packages/compression/index.ts @@ -4,10 +4,11 @@ import { createBrotliCompress, createDeflate, createGzip } from "node:zlib"; export const DefaultCompression = "br"; -export type IComressFormats = "auto" | "br" | "deflate" | "gzip"; +/** The `format` of a compression: `auto` negotiates it, the others are fixed */ +export type ICompressFormats = "auto" | "br" | "deflate" | "gzip"; export interface ICompressionOptions extends IMiddlewareParams { - format?: IComressFormats; + format?: ICompressFormats; } type IWrite = ( @@ -23,14 +24,14 @@ type IEnd = ( ) => void; export class Compression extends Middleware { - readonly #format: IComressFormats; + readonly #format: ICompressFormats; public constructor({ format = "auto", ...rest }: ICompressionOptions = {}) { super(rest); this.#format = format; } - public get format(): IComressFormats { + public get format(): ICompressFormats { return this.#format; } @@ -199,8 +200,8 @@ export class Compression extends Middleware { static #getFormat( context: Context, - format: IComressFormats, - ): Exclude | "identity" { + format: ICompressFormats, + ): Exclude | "identity" { if (format !== "auto") { return format; } @@ -223,7 +224,7 @@ export class Compression extends Middleware { qualities.get(coding) ?? wildcard; // The best acceptable coding (on a tie, in the order of `#formats`) - let best: Exclude | null = null; + let best: Exclude | null = null; for (const coding of Compression.#formats) { if ( quality(coding) > 0 && diff --git a/packages/compression/test/compression.test.ts b/packages/compression/test/compression.test.ts index d651bab6..54ccf1e2 100644 --- a/packages/compression/test/compression.test.ts +++ b/packages/compression/test/compression.test.ts @@ -22,7 +22,7 @@ import { request } from "undici"; import { Compression, DefaultCompression, - type IComressFormats, + type ICompressFormats, } from "../index.js"; function brotliDecompress(data: InputType): Promise { @@ -434,7 +434,7 @@ describe("Compression", () => { it("Multiple compressions", async () => { const expected = "Hello World"; - const formats: readonly IComressFormats[] = ["gzip", "br", "deflate"]; + const formats: readonly ICompressFormats[] = ["gzip", "br", "deflate"]; app.use( ...formats.map((format) => new Compression({ format })), new TestMiddleware(expected), From e73c969152fa165fb876fe5583cee8a8a19078bc Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 02:24:07 +0300 Subject: [PATCH 168/675] feat!: decide the compression when the headers are committed in @binden/compression The compression decided when it ran, before the response existed: it set `Vary` before checking whether the headers were sent, compressed responses of any type and size, chose `br` for a request without `Accept-Encoding`, and sent an uncompressed body to a client that had refused `identity`. A `304` kept the strong `ETag` of a body whose `200` had a weak one. `run()` now only installs the compression on the response: once (a second compression on the same response does nothing), and not at all once its headers are sent. The decision is taken when the headers are committed (by `writeHead()` with its headers, `flushHeaders()`, or the first `write()` or `end()`), with the final status and representation, the same for `GET` and `HEAD`. A response is sent as it is without a body (`1xx`, `204`, `205`, an empty one), for a range (`206`, `Content-Range`), when it is encoded already (an `identity` encoding is removed), with `no-transform`, after `skipCompression()`, or when `filter` or `threshold` refuse it; a fixed `format` does not bypass any of these. `auto` negotiates among `encodings` (`br`, `gzip` and `deflate`, `zstd` when listed; a `TypeError` without zlib support) by the weights of `Accept-Encoding`, `*` and `identity`, the earlier offer on a tie, and adds `Vary` at the commit. No `Accept-Encoding` gets no coding. When neither a coding nor `identity` is acceptable, the response is replaced with an empty `406`, and the later writes are discarded (their callbacks run). A compressed response loses `Content-Length` and `Accept-Ranges` and gets a weak `ETag`; a `304` gets the decision of the `200` that it stands for, without `Content-Encoding`. The synchronous `filter` (`isCompressible()` by default: text but event streams, JSON, XML, JavaScript, WebAssembly) must return a boolean: anything else is a configuration error, logged, that replaces the response with an empty `500`. `threshold` (1024) applies to `Content-Length`; a body without a length is compressed. The compressor is created for a compressed body only, and keeps the backpressure and the errors of the writes; a write after `end()` fails as with Node.js, without destroying the response. BREAKING CHANGE: a request without `Accept-Encoding` gets an uncompressed response; a request that refuses `identity` without an acceptable coding gets `406`; responses smaller than 1024 bytes, or of a type that `isCompressible()` refuses (e.g. images, event streams, no `Content-Type`), are not compressed (see `threshold` and `filter`); a second compression on the same response no longer adds a coding. Co-Authored-By: Claude Opus 5.5 --- packages/compression/index.ts | 377 +++----- packages/compression/package.json | 1 + packages/compression/src/codings.ts | 38 + packages/compression/src/filter.ts | 45 + packages/compression/src/negotiate.ts | 52 ++ packages/compression/src/response.ts | 565 +++++++++++ packages/compression/test/compression.test.ts | 882 ------------------ packages/compression/test/conditional.test.ts | 283 ++++++ packages/compression/test/decision.test.ts | 479 ++++++++++ packages/compression/test/filter.test.ts | 264 ++++++ .../test/fixtures/filter-rejection.ts | 59 ++ packages/compression/test/helpers.ts | 226 +++++ packages/compression/test/negotiation.test.ts | 214 +++++ packages/compression/test/options.test.ts | 77 ++ packages/compression/test/process.test.ts | 28 + packages/compression/test/streaming.test.ts | 327 +++++++ 16 files changed, 2786 insertions(+), 1131 deletions(-) create mode 100644 packages/compression/src/codings.ts create mode 100644 packages/compression/src/filter.ts create mode 100644 packages/compression/src/negotiate.ts create mode 100644 packages/compression/src/response.ts delete mode 100644 packages/compression/test/compression.test.ts create mode 100644 packages/compression/test/conditional.test.ts create mode 100644 packages/compression/test/decision.test.ts create mode 100644 packages/compression/test/filter.test.ts create mode 100644 packages/compression/test/fixtures/filter-rejection.ts create mode 100644 packages/compression/test/helpers.ts create mode 100644 packages/compression/test/negotiation.test.ts create mode 100644 packages/compression/test/options.test.ts create mode 100644 packages/compression/test/process.test.ts create mode 100644 packages/compression/test/streaming.test.ts diff --git a/packages/compression/index.ts b/packages/compression/index.ts index f96b4366..01dd0ecf 100644 --- a/packages/compression/index.ts +++ b/packages/compression/index.ts @@ -1,272 +1,151 @@ -import { Context, type IMiddlewareParams, Middleware } from "binden"; -import { Writable, pipeline } from "node:stream"; -import { createBrotliCompress, createDeflate, createGzip } from "node:zlib"; - +import { + BindenResponse, + type ContentType, + type Context, + type IMiddlewareParams, + Middleware, +} from "binden"; +import { CompressedResponse, type ISettings, skip } from "./src/response.js"; +import { type ICompressEncodings, codings, hasZstd } from "./src/codings.js"; +import { type ICompressionFilter, isCompressible } from "./src/filter.js"; + +export { type ICompressionFilter, isCompressible } from "./src/filter.js"; +export type { ICompressEncodings } from "./src/codings.js"; + +/** The preferred content coding: the first of the default `encodings` */ export const DefaultCompression = "br"; /** The `format` of a compression: `auto` negotiates it, the others are fixed */ -export type ICompressFormats = "auto" | "br" | "deflate" | "gzip"; +export type ICompressFormats = ICompressEncodings | "auto"; +/** The options of a `Compression` */ export interface ICompressionOptions extends IMiddlewareParams { - format?: ICompressFormats; + /** + * The content coding: `auto` (the default) negotiates it with + * `Accept-Encoding` among `encodings`; a fixed one compresses every response + * that may be compressed, whatever the request accepts + */ + format?: ICompressFormats | undefined; + /** + * The codings that `auto` offers, the preferred first (`["br", "gzip", + * "deflate"]`): `zstd` is offered only when listed + */ + encodings?: readonly ICompressEncodings[] | undefined; + /** + * The `Content-Length` (in bytes) below which a response is not compressed + * (`1024`); a response without one is compressed + */ + threshold?: number | undefined; + /** + * Whether a response is compressed, by its `Content-Type` (`null` without a + * valid one), called synchronously when its headers are committed: it must + * return a boolean, anything else (e.g. a promise) is a configuration error + * that replaces the response with `500`. `isCompressible()` by default. + */ + filter?: ICompressionFilter | undefined; } -type IWrite = ( - chunk: unknown, - encoding?: BufferEncoding | ((error?: Error | null) => void), - cb?: (error?: Error | null) => void, -) => boolean; - -type IEnd = ( - chunk?: unknown, - encoding?: BufferEncoding, - cb?: () => void, -) => void; +const formats: readonly unknown[] = ["auto", ...codings]; + +const defaultEncodings: readonly ICompressEncodings[] = [ + "br", + "gzip", + "deflate", +]; + +/** The codings of `encodings`: a non-empty list of distinct codings */ +function checkEncodings(encodings: unknown): readonly ICompressEncodings[] { + if ( + !Array.isArray(encodings) || + encodings.length === 0 || + !encodings.every((coding) => + codings.includes(coding as ICompressEncodings), + ) || + new Set(encodings).size !== encodings.length + ) { + throw new TypeError( + "`encodings` is not a non-empty list of distinct codings (br, deflate, gzip, zstd)", + ); + } + return Object.freeze([...(encodings as ICompressEncodings[])]); +} +/** + * Compresses the responses (`br`, `gzip`, `deflate` and `zstd`). The decision + * is taken for each response when its headers are committed: see the README. + */ export class Compression extends Middleware { - readonly #format: ICompressFormats; - - public constructor({ format = "auto", ...rest }: ICompressionOptions = {}) { + readonly #settings: ISettings; + + /** + * Validates the options: a `TypeError` (or `RangeError`) for an invalid one, + * also for `zstd` when `node:zlib` does not have it + */ + public constructor(options: ICompressionOptions = {}) { + const { + format = "auto", + encodings, + threshold = 1024, + filter = (_context: Context, type: ContentType | null): boolean => + isCompressible(type), + ...rest + } = options; super(rest); - this.#format = format; - } - - public get format(): ICompressFormats { - return this.#format; - } - - public run(context: Context): void { - const { response } = context; - - // Sent headers can no longer change (not even `Vary`): the response is - // left as it is - if (response.headersSent) { - return; + if (!formats.includes(format)) { + throw new TypeError( + "`format` is not one of auto, br, deflate, gzip and zstd", + ); + } else if (typeof encodings !== "undefined" && format !== "auto") { + throw new TypeError("`encodings` applies to the `auto` format only"); + } else if (typeof threshold !== "number") { + throw new TypeError("`threshold` is not a number"); + } else if (!Number.isSafeInteger(threshold) || threshold < 0) { + throw new RangeError("`threshold` is not a non-negative integer"); + } else if (typeof filter !== "function") { + throw new TypeError("`filter` is not a function"); } - - if (this.format === "auto") { - Compression.#varyOnAcceptEncoding(response); - } - - const format = Compression.#getFormat(context, this.format); - - if (format === "identity") { - return; + const offered = + typeof encodings === "undefined" + ? defaultEncodings + : checkEncodings(encodings); + const used = format === "auto" ? offered : [format]; + if (used.includes("zstd") && !hasZstd()) { + throw new TypeError("zstd is not supported by this version of Node.js"); } - const compress = - format === "br" - ? createBrotliCompress() - : format === "deflate" - ? createDeflate() - : createGzip(); - - // `write`/`end` are replaced on the response itself, so that every writer - // (the following middlewares, `sendFile`, the error handler) goes through - // the compressor; several compressions chain their replaced methods. - const write = response.write.bind(response) as IWrite; - const end = response.end.bind(response) as IEnd; - - // Compression starts with the first body chunk. A `Content-Length` of the - // uncompressed body (e.g. from `sendFile`) is dropped. Nothing is compressed - // when the headers have already been sent, nor without a body (1xx, 204, - // 205, 304), nor for partial responses: their ranges refer to the - // uncompressed body (RFC 9110, section 14.1.2). A response to `HEAD` gets - // the headers of the compressed response, but no body. - const head = context.request.method === "HEAD"; - let active: boolean | null = null; - let ended = false; - const activate = (): boolean => { - if (active === null) { - const { statusCode } = response; - active = - !response.headersSent && - statusCode >= 200 && - statusCode !== 204 && - statusCode !== 205 && - statusCode !== 304 && - statusCode !== 206 && - !response.hasHeader("Content-Range"); - if (!active || head) { - compress.destroy(); - } - if (active) { - const raw = response.getHeader("Content-Encoding"); - response.setHeader( - "Content-Encoding", - typeof raw === "undefined" - ? format - : [...(Array.isArray(raw) ? raw : [`${raw}`]), format], - ); - response.removeHeader("Content-Length"); - // A strong validator of the uncompressed body does not validate the - // compressed one (RFC 9110, section 8.8.3.3): it becomes weak - const etag = response.getHeader("ETag"); - if (typeof etag === "string" && !etag.startsWith("W/")) { - response.setHeader("ETag", `W/${etag}`); - } - } - if (active && !head) { - // Backpressure in both directions: a compressed chunk is acknowledged - // once the response has flushed it (so the compressor waits while the - // response is full), and `drain` of the compressor is the `drain` - // that writers of the response wait for. Errors of the chain and a - // response closed early (client gone) end both sides. - pipeline( - compress.on("drain", () => response.emit("drain")), - new Writable({ - write(chunk, _encoding, callback): void { - write(chunk, () => { - callback(); - }); - }, - final(callback): void { - end(); - callback(); - }, - }), - (error) => { - if (error) { - response.destroy(); - } - }, - ); - response.once("close", () => { - compress.destroy(); - }); - } - } - return active && !head; - }; - - response.write = (( - chunk: unknown, - encoding?: BufferEncoding, - cb?: (error?: Error | null) => void, - ): boolean => { - if (activate()) { - return compress.write(chunk, encoding as BufferEncoding, cb); - } - return write(chunk, encoding, cb); - }) as typeof response.write; - - // As for an uncompressed response, `writableEnded` is set by `end()`, the - // `end()` callback runs once the response has finished (or, when it has - // already finished, with `ERR_STREAM_ALREADY_FINISHED`), and a second - // `end()` writes nothing. - Object.defineProperty(response, "writableEnded", { - configurable: true, - get: () => ended, + this.#settings = Object.freeze({ + format, + encodings: offered, + threshold, + filter, }); - - // Written data goes into the compressor before the headers are sent: the - // response is started (see `BindenResponse.started`) once compression is - Object.defineProperty(response, "started", { - configurable: true, - get: (): boolean => - active === true || - (Reflect.get( - Object.getPrototypeOf(response) as object, - "started", - response, - ) as boolean), - }); - - response.end = (...args: unknown[]): typeof response => { - const callback = args.find((arg) => typeof arg === "function"); - if (typeof callback === "function") { - if (response.writableFinished) { - end(callback); - } else { - response.once("finish", () => { - (callback as () => void)(); - }); - } - } - if (!ended) { - ended = true; - const [chunk, encoding] = args.filter( - (arg) => typeof arg !== "function", - ) as [unknown, BufferEncoding]; - if (activate()) { - compress.end(chunk, encoding); - } else { - end(chunk, encoding); - } - } - return response; - }; } - static #getFormat( - context: Context, - format: ICompressFormats, - ): Exclude | "identity" { - if (format !== "auto") { - return format; - } - - const { request } = context; - - // No `Accept-Encoding` means that any coding is acceptable - if (typeof request.headers["accept-encoding"] === "undefined") { - return DefaultCompression; - } - - // The effective quality of a coding is its own `q` (`x-gzip` is `gzip`), - // otherwise the one of `*`, otherwise 0 (not acceptable) - const qualities = new Map(); - for (const { encoding, q_value } of request.accept_encoding) { - qualities.set(encoding === "x-gzip" ? "gzip" : encoding, q_value ?? 1); - } - const wildcard = qualities.get("*") ?? 0; - const quality = (coding: string): number => - qualities.get(coding) ?? wildcard; - - // The best acceptable coding (on a tie, in the order of `#formats`) - let best: Exclude | null = null; - for (const coding of Compression.#formats) { - if ( - quality(coding) > 0 && - (best === null || quality(coding) > quality(best)) - ) { - best = coding; - } - } - - // `identity` wins only when it is explicitly preferred. When no coding is - // acceptable, the header is disregarded and the response is not encoded - // (RFC 9110, section 12.5.3) instead of replying with 406. - const identity = qualities.get("identity"); - if ( - best === null || - (typeof identity === "number" && identity > quality(best)) - ) { - return "identity"; - } - - return best; + /** The `format` of the compression */ + public get format(): ICompressFormats { + return this.#settings.format; } - /** Response codings in the order of preference (the first one is the default) */ - static readonly #formats = [DefaultCompression, "gzip", "deflate"] as const; - - /** The response depends on `Accept-Encoding` (keeps the existing `Vary` values) */ - static #varyOnAcceptEncoding(response: Context["response"]): void { - const raw = response.getHeader("Vary"); - const values = ( - Array.isArray(raw) ? raw : typeof raw === "undefined" ? [] : [`${raw}`] - ) - .flatMap((value) => value.split(",")) - .map((value) => value.trim()) - .filter((value) => value !== ""); + /** + * Installs the compression on the response, unless its headers have been + * sent (it is then left as it is) or a compression is installed on it + * already: the decision is taken when its headers are committed + */ + public run(context: Context): void { + CompressedResponse.install(context, this.#settings); + } +} - if ( - !values.some((v) => v === "*" || v.toLowerCase() === "accept-encoding") - ) { - response.setHeader("Vary", [...values, "Accept-Encoding"].join(", ")); - } +/** + * Leaves one response uncompressed (e.g. a stream that must not wait in a + * compressor): call it before its headers are committed + */ +export function skipCompression(response: BindenResponse): void { + if (!(response instanceof BindenResponse)) { + throw new TypeError("The response is not a `BindenResponse`"); } + skip(response); } +/** A compression with the default options */ export default new Compression(); diff --git a/packages/compression/package.json b/packages/compression/package.json index 7a6f460a..9f646480 100644 --- a/packages/compression/package.json +++ b/packages/compression/package.json @@ -30,6 +30,7 @@ "br", "gzip", "deflate", + "zstd", "binden" ], "author": "Sergey Bakulin ", diff --git a/packages/compression/src/codings.ts b/packages/compression/src/codings.ts new file mode 100644 index 00000000..dd49701d --- /dev/null +++ b/packages/compression/src/codings.ts @@ -0,0 +1,38 @@ +import type { Transform } from "node:stream"; +import zlib from "node:zlib"; + +/** A content coding that the compression produces */ +export type ICompressEncodings = "br" | "deflate" | "gzip" | "zstd"; + +/** Every content coding that the compression produces */ +export const codings: readonly ICompressEncodings[] = [ + "br", + "deflate", + "gzip", + "zstd", +]; + +/** A compressor of `node:zlib` */ +export type ICompressor = Transform & zlib.Zlib; + +/** + * Whether `node:zlib` has zstd: it is read when called (not when imported), + * so that the tests can take it away + */ +export function hasZstd(): boolean { + return ( + typeof (zlib as Partial).createZstdCompress === "function" + ); +} + +/** A compressor of a coding */ +export function createCompressor(coding: ICompressEncodings): ICompressor { + if (coding === "br") { + return zlib.createBrotliCompress(); + } else if (coding === "deflate") { + return zlib.createDeflate(); + } else if (coding === "gzip") { + return zlib.createGzip(); + } + return zlib.createZstdCompress(); +} diff --git a/packages/compression/src/filter.ts b/packages/compression/src/filter.ts new file mode 100644 index 00000000..8e980df9 --- /dev/null +++ b/packages/compression/src/filter.ts @@ -0,0 +1,45 @@ +import type { ContentType, Context } from "binden"; + +/** + * Decides whether a response is compressed, by its `Content-Type` (`null` + * without a valid one): called synchronously, it must return a boolean + */ +export type ICompressionFilter = ( + context: Context, + content_type: ContentType | null, +) => boolean; + +/** The compressible media types that are neither `text/*` nor `+json`/`+xml` */ +const types: ReadonlySet = new Set([ + "application/ecmascript", + "application/javascript", + "application/json", + "application/vnd.ms-fontobject", + "application/wasm", + "application/x-javascript", + "application/x-www-form-urlencoded", + "application/xml", + "font/otf", + "font/ttf", + "image/bmp", + "image/vnd.microsoft.icon", + "image/x-icon", +]); + +/** + * The default `filter` of the compression: whether a media type is worth + * compressing. Text (`text/*`, but `text/event-stream`, whose events would + * wait in the compressor), JSON and XML (also as `+json` and `+xml`, such as + * `image/svg+xml`), JavaScript, WebAssembly and uncompressed fonts and + * images are; a response without a valid `Content-Type` is not. + */ +export function isCompressible(content_type: ContentType | null): boolean { + if (content_type === null) { + return false; + } + const type = content_type.type.toLowerCase(); + if (type.startsWith("text/")) { + return type !== "text/event-stream"; + } + return type.endsWith("+json") || type.endsWith("+xml") || types.has(type); +} diff --git a/packages/compression/src/negotiate.ts b/packages/compression/src/negotiate.ts new file mode 100644 index 00000000..b95387cc --- /dev/null +++ b/packages/compression/src/negotiate.ts @@ -0,0 +1,52 @@ +import { AcceptEncoding } from "binden"; +import type { ICompressEncodings } from "./codings.js"; + +/** A content coding in the form of the offers (`x-gzip` is `gzip`) */ +function canonical(encoding: string): string { + return encoding === "x-gzip" ? "gzip" : encoding; +} + +/** + * The weight of a content coding in `Accept-Encoding`: the one of its own + * element (`x-gzip` is `gzip`), otherwise the one of `*`, otherwise `null` + * (not named by the header) + */ +function weightOf( + elements: readonly AcceptEncoding[], + coding: string, +): number | null { + const element = + elements.find(({ encoding }) => canonical(encoding) === coding) ?? + elements.find(({ encoding }) => encoding === "*"); + return typeof element === "undefined" ? null : (element.q_value ?? 1); +} + +/** + * The content coding of a response (RFC 9110, section 12.5.3): `identity` + * without `Accept-Encoding` (or with an empty one), otherwise the offer of the + * greatest weight (on a tie, the earlier offer), unless `identity` weighs + * more. `identity` is acceptable unless excluded (`identity;q=0`, or `*;q=0` + * without `identity`): `false` when nothing is acceptable. + */ +export function negotiate( + header: string | undefined, + offers: readonly ICompressEncodings[], +): ICompressEncodings | "identity" | false { + // No element names an offer without the header: `identity` is chosen + const elements = AcceptEncoding.fromString(header); + let best: ICompressEncodings | null = null; + let weight = 0; + for (const offer of offers) { + const offered = weightOf(elements, offer) ?? 0; + if (offered > weight) { + best = offer; + weight = offered; + } + } + // Not named by the header, `identity` is acceptable, but the least preferred + const identity = weightOf(elements, "identity"); + if (best !== null && (identity === null || weight >= identity)) { + return best; + } + return identity === 0 ? false : "identity"; +} diff --git a/packages/compression/src/response.ts b/packages/compression/src/response.ts new file mode 100644 index 00000000..f516c5de --- /dev/null +++ b/packages/compression/src/response.ts @@ -0,0 +1,565 @@ +import { + type BindenRequest, + type BindenResponse, + ContentType, + type Context, + ct_text, +} from "binden"; +import { + type ICompressEncodings, + type ICompressor, + createCompressor, +} from "./codings.js"; +import { + type OutgoingHttpHeader, + type OutgoingHttpHeaders, + STATUS_CODES, +} from "node:http"; +import type { ICompressionFilter } from "./filter.js"; +import { negotiate } from "./negotiate.js"; + +/** The settings of a compression, validated */ +export interface ISettings { + readonly format: ICompressEncodings | "auto"; + readonly encodings: readonly ICompressEncodings[]; + readonly threshold: number; + readonly filter: ICompressionFilter; +} + +/** + * What a response becomes once its headers are committed: sent as it is, + * compressed, or replaced with an empty one (`406`, or `500` for a `filter` + * that has failed) + */ +type IOutcome = + | { readonly kind: "as_is" } + | { readonly kind: "compress"; readonly coding: ICompressEncodings } + | { readonly kind: "replace"; readonly status: 406 | 500 }; + +/** + * Where the body goes: the decision is not taken yet (`pending`), to the + * response as it is, to the compressor, or nowhere once the response has + * been replaced + */ +type ISink = "as_is" | "compressor" | "discard" | "pending"; + +type IResponse = BindenResponse; + +type ICallback = (error?: Error | null) => void; + +const AS_IS: IOutcome = { kind: "as_is" }; + +/** The headers of a representation, which an empty replacement does not have */ +const representation = [ + "Accept-Ranges", + "Content-Disposition", + "Content-Encoding", + "Content-Language", + "Content-Length", + "Content-Location", + "Content-Range", + "Content-Type", + "ETag", + "Last-Modified", + "Transfer-Encoding", +]; + +/** The responses that a compression has been installed on */ +const installed = new WeakSet(); + +/** The responses that are not compressed (see `skipCompression()`) */ +const skipped = new WeakSet(); + +/** The values of a header as a list (of a comma-separated list) */ +function listOf(value: number | string | string[] | undefined): string[] { + const values = Array.isArray(value) ? value : [`${value ?? ""}`]; + return values + .flatMap((item) => item.split(",")) + .map((item) => item.trim().toLowerCase()) + .filter((item) => item !== ""); +} + +/** Whether `Content-Encoding` names `identity` only (no coding at all) */ +function isIdentity(value: number | string | string[]): boolean { + const list = listOf(value); + return list.length > 0 && list.every((coding) => coding === "identity"); +} + +/** Whether `Cache-Control` has the `no-transform` directive */ +function noTransform(value: number | string | string[] | undefined): boolean { + return listOf(value).includes("no-transform"); +} + +/** The value of `Content-Length` (as binden reads it), or `null` without a valid one */ +function lengthOf( + value: number | string | string[] | undefined, +): number | null { + const length = Number(value ?? Number.NaN); + return Number.isSafeInteger(length) && length >= 0 ? length : null; +} + +/** The size of a chunk of the body in bytes, or `null` when it is not one */ +function sizeOf(chunk: unknown, encoding: unknown): number | null { + if (typeof chunk === "string") { + const coding = + typeof encoding === "string" && Buffer.isEncoding(encoding) + ? encoding + : "utf8"; + return Buffer.byteLength(chunk, coding); + } else if (chunk instanceof Uint8Array) { + return chunk.byteLength; + } else if (typeof chunk === "number" || typeof chunk === "bigint") { + // As `send()` sends them + return `${chunk}`.length; + } + return null; +} + +/** + * Sets the headers given to `writeHead()` as Node.js does once headers have + * been set: an object sets them, a list (flat, or of pairs) replaces each of + * its names with its values. `false` for a list that is not of pairs (which + * Node.js rejects). + */ +function setGiven( + response: IResponse, + given: OutgoingHttpHeader[] | OutgoingHttpHeaders | undefined, +): boolean { + if (Array.isArray(given)) { + const flat: unknown[] = Array.isArray(given[0]) ? given.flat() : given; + if (flat.length % 2 !== 0) { + return false; + } + const pairs: [string, OutgoingHttpHeader][] = []; + for (let index = 0; index < flat.length; index += 2) { + pairs.push(flat.slice(index, index + 2) as [string, OutgoingHttpHeader]); + } + for (const [name] of pairs) { + response.removeHeader(name); + } + for (const [name, value] of pairs) { + if (name) { + response.appendHeader(name, value as string[] | string); + } + } + } else if (given) { + for (const [name, value] of Object.entries(given)) { + if (name) { + response.setHeader(name, value as OutgoingHttpHeader); + } + } + } + return true; +} + +/** The result of a function, or `ok: false` once it throws */ +function attempt(fn: () => T): { ok: true; value: T } | { ok: false } { + try { + return { ok: true, value: fn() }; + } catch { + return { ok: false }; + } +} + +/** The error of a write after `end()` */ +function writeAfterEnd(): Error { + return Object.assign(new Error("write after end"), { + code: "ERR_STREAM_WRITE_AFTER_END", + }); +} + +/** + * Leaves one response uncompressed: called before its headers are committed + * (afterwards, it has no effect) + */ +export function skip(response: object): void { + skipped.add(response); +} + +/** + * The compression of one response. Its methods are replaced on the response + * itself, so that every writer (the following middlewares, `sendFile()`, the + * error handler) goes through it, and the decision is taken once, when the + * headers are committed: by `writeHead()`, `flushHeaders()`, + * or the first `write()` or `end()`. + */ +export class CompressedResponse { + readonly #context: Context; + readonly #settings: ISettings; + readonly #response: IResponse; + // The methods that the compression replaces, as they were + readonly #writeHead: IResponse["writeHead"]; + readonly #write: IResponse["write"]; + readonly #end: IResponse["end"]; + readonly #send: IResponse["send"]; + #sink: ISink = "pending"; + #compressor: ICompressor | null = null; + /** Whether the application has ended a compressed response */ + #ended = false; + /** The size of the body given to `send()`, which a `304` does not have */ + #hint: number | null = null; + + private constructor(context: Context, settings: ISettings) { + const { response } = context; + this.#context = context; + this.#settings = settings; + this.#response = response; + this.#writeHead = response.writeHead.bind(response); + this.#write = response.write.bind(response); + this.#end = response.end.bind(response); + this.#send = response.send.bind(response); + } + + /** + * Installs the compression on the response of a context, unless its headers + * have been sent (it is then left as it is), or a compression is installed + * on it already + */ + public static install(context: Context, settings: ISettings): void { + const { response } = context; + if (response.headersSent || installed.has(response)) { + return; + } + installed.add(response); + new CompressedResponse(context, settings).#hook(); + } + + /** Replaces the methods of the response */ + #hook(): void { + const response = this.#response; + response.writeHead = (...args: unknown[]): IResponse => + this.#onWriteHead(args); + response.write = ((...args: unknown[]): boolean => + this.#onWrite(args)) as IResponse["write"]; + response.end = ((...args: unknown[]): IResponse => + this.#onEnd(args)) as IResponse["end"]; + response.send = (...args: unknown[]): Promise => { + if (this.#sink === "pending") { + this.#hint = sizeOf(args[0], args[1]); + } + return Reflect.apply(this.#send, response, args) as Promise; + }; + // As for an uncompressed response, `writableEnded` is set by `end()` + // (a compressed response is ended once the compressor is) + Object.defineProperty(response, "writableEnded", { + configurable: true, + get: (): boolean => + this.#ended || + (Reflect.get( + Object.getPrototypeOf(response) as object, + "writableEnded", + response, + ) as boolean), + }); + } + + /** + * `writeHead()`: the headers it is given are set first (as Node.js does), + * then the decision is taken, and the headers are committed + */ + #onWriteHead(args: unknown[]): IResponse { + const response = this.#response; + const [code, reason, headers] = args; + // As Node.js reads it + const status = Number(code) | 0; + const given = ( + typeof reason === "string" ? headers : (headers ?? reason) + ) as OutgoingHttpHeader[] | OutgoingHttpHeaders | undefined; + if ( + this.#sink !== "pending" || + response.headersSent || + status < 100 || + status > 999 || + !setGiven(response, given) + ) { + // Decided already, or rejected by Node.js + return Reflect.apply(this.#writeHead, response, args) as IResponse; + } + response.statusCode = status; + this.#apply(this.#decide(null)); + // Decided now (the type does not see it) + if ((this.#sink as ISink) === "discard") { + return response; + } + return typeof reason === "string" + ? this.#writeHead(status, reason) + : this.#writeHead(status); + } + + /** `write()`: the decision is taken first, unless it has been */ + #onWrite(args: unknown[]): boolean { + if (this.#sink === "pending") { + this.#decideForBody(null); + } + const [chunk, second, third] = args; + const callback = (typeof second === "function" ? second : third) as + ICallback | undefined; + if (this.#sink === "compressor") { + const compressor = this.#compressor as ICompressor; + if (this.#ended) { + // As Node.js, but the response is sent (not destroyed, nor crashing + // the process with an `error` event) + if (callback) { + process.nextTick(callback, writeAfterEnd()); + } + return false; + } + const encoding = typeof second === "string" ? second : "utf8"; + return compressor.write( + chunk, + encoding as BufferEncoding, + callback as ICallback, + ); + } else if (this.#sink === "discard") { + // The response has been replaced: its writes are discarded + if (callback) { + process.nextTick(callback); + } + return true; + } + return Reflect.apply(this.#write, this.#response, args) as boolean; + } + + /** `end()`: the decision is taken first (with the size of its chunk), unless it has been */ + #onEnd(args: unknown[]): IResponse { + const response = this.#response; + const [first, second, third] = args; + const chunk: unknown = typeof first === "function" ? null : first; + const encoding = typeof second === "string" ? second : "utf8"; + const callback = [first, second, third].find( + (arg) => typeof arg === "function", + ) as ICallback | undefined; + if (this.#sink === "pending") { + // Without writes before it, its chunk is the body + this.#decideForBody( + chunk === null || typeof chunk === "undefined" + ? 0 + : sizeOf(chunk, encoding), + ); + } + if (this.#sink === "compressor") { + // As Node.js: the callback runs once the response has finished (or + // with `ERR_STREAM_ALREADY_FINISHED` once it has), and a second `end()` + // writes nothing + if (callback) { + if (response.writableFinished) { + this.#end(callback); + } else { + response.once("finish", () => { + callback(); + }); + } + } + if (!this.#ended) { + this.#ended = true; + (this.#compressor as ICompressor).end( + chunk, + encoding as BufferEncoding, + ); + } + return response; + } else if (this.#sink === "discard") { + // Ended already: only its callback runs + return Reflect.apply( + this.#end, + response, + callback ? [callback] : [], + ) as IResponse; + } + return Reflect.apply(this.#end, response, args) as IResponse; + } + + /** + * Takes the decision for the body that is written (`size` is the size of + * the whole body when `end()` writes it, `null` otherwise): a compressed + * body commits the headers at once + */ + #decideForBody(size: number | null): void { + const response = this.#response; + if (response.headersSent) { + // Sent by another path: left as it is + this.#sink = "as_is"; + return; + } + this.#apply(this.#decide(size)); + if (this.#sink === "compressor") { + this.#writeHead(response.statusCode); + } + } + + /** + * The decision, by the status and the headers of the response, the same + * for `GET` and `HEAD`. A `304` gets the decision of the `200` that it + * stands for: the same validators and `Vary`, without a body. + */ + #decide(size: number | null): IOutcome { + const { request } = this.#context; + const response = this.#response; + const status = response.statusCode; + if ( + status < 200 || + status === 204 || + status === 205 || + // Ranges refer to the representation as it is (RFC 9110, section 14.1.2) + status === 206 || + response.hasHeader("Content-Range") + ) { + return AS_IS; + } + const encoding = response.getHeader("Content-Encoding"); + if (typeof encoding !== "undefined") { + if (!isIdentity(encoding)) { + // Encoded already (e.g. a precompressed file) + return AS_IS; + } + // Not a content coding (RFC 9110, section 8.4.1) + response.removeHeader("Content-Encoding"); + } + if ( + noTransform(response.getHeader("Cache-Control")) || + skipped.has(response) + ) { + return AS_IS; + } + + const head = request.method === "HEAD"; + const length = + lengthOf(response.getHeader("Content-Length")) ?? + (status === 304 ? this.#hint : head ? null : size); + if (length === 0) { + // No body + return AS_IS; + } + const filtered = this.#filter(); + if (filtered === null) { + return { kind: "replace", status: 500 }; + } else if ( + !filtered || + (length !== null && length < this.#settings.threshold) + ) { + return AS_IS; + } + + const { format, encodings } = this.#settings; + if (format !== "auto") { + return { kind: "compress", coding: format }; + } + response.vary("Accept-Encoding"); + const coding = negotiate(request.headers["accept-encoding"], encodings); + if (coding === "identity") { + return AS_IS; + } else if (coding === false) { + return { kind: "replace", status: 406 }; + } + return { kind: "compress", coding }; + } + + /** + * The result of the `filter`: `null` when it fails, or does not return a + * boolean (a configuration error, logged) + */ + #filter(): boolean | null { + const context = this.#context; + // `send()` sends a body as `text/plain` by default, but not with a `304` + const raw = + this.#response.getHeader("Content-Type") ?? + (this.#hint === null ? null : ct_text); + const type = typeof raw === "string" ? ContentType.fromString(raw) : null; + const outcome = attempt(() => this.#settings.filter(context, type)); + if (!outcome.ok) { + context.log.error("The `filter` of the compression has failed"); + return null; + } + const result: unknown = outcome.value; + if (typeof result === "boolean") { + return result; + } + // E.g. a promise: its rejection is observed, never trusted + Promise.resolve(result).catch(() => null); + context.log.error( + "The `filter` of the compression has not returned a boolean", + ); + return null; + } + + /** Applies a decision to the headers, and sets where the body goes */ + #apply(outcome: IOutcome): void { + const response = this.#response; + if (outcome.kind === "replace") { + this.#replace(outcome.status); + return; + } else if (outcome.kind === "compress") { + // A `304` has no body, nor its encoding (a cache would update the + // stored response with it) + const bodiless = response.statusCode === 304; + if (!bodiless) { + response.setHeader("Content-Encoding", outcome.coding); + } + response.removeHeader("Content-Length"); + response.removeHeader("Accept-Ranges"); + // A strong validator of the uncompressed body does not validate the + // compressed one (RFC 9110, section 8.8.3.3): it becomes weak + const etag = response.getHeader("ETag"); + if (typeof etag === "string" && !etag.startsWith("W/")) { + response.setHeader("ETag", `W/${etag}`); + } + if (!bodiless && this.#context.request.method !== "HEAD") { + this.#compressor = this.#start(outcome.coding); + this.#sink = "compressor"; + return; + } + } + this.#sink = "as_is"; + } + + /** + * Replaces the response with an empty one of the status, and sends it: the + * writes of the application are then discarded + */ + #replace(status: 406 | 500): void { + const response = this.#response; + for (const name of representation) { + response.removeHeader(name); + } + response.setHeader("Content-Length", 0); + this.#sink = "discard"; + this.#writeHead(status, STATUS_CODES[status] as string); + this.#end(); + } + + /** + * Creates the compressor, whose output is written to the response. + * Backpressure goes both ways: the compressor pauses while the response is + * full, and the `drain` of the compressor is the `drain` that writers of + * the response wait for. An error of the compressor, or a response closed + * early (the client has gone away), end both sides. + */ + #start(coding: ICompressEncodings): ICompressor { + const response = this.#response; + const compressor = createCompressor(coding); + compressor + .on("data", (chunk: Buffer) => { + const accepted = this.#write(chunk); + if (!accepted) { + compressor.pause(); + response.once("drain", () => { + compressor.resume(); + }); + } + }) + .on("drain", () => { + response.emit("drain"); + }) + .on("error", () => { + response.destroy(); + }) + .once("end", () => { + this.#end(); + }); + response.once("close", () => { + // Its pending writes fail + compressor.destroy(); + }); + return compressor; + } +} diff --git a/packages/compression/test/compression.test.ts b/packages/compression/test/compression.test.ts deleted file mode 100644 index 54ccf1e2..00000000 --- a/packages/compression/test/compression.test.ts +++ /dev/null @@ -1,882 +0,0 @@ -/* eslint-disable init-declarations, sort-imports */ -import { deepStrictEqual, ok, rejects } from "node:assert"; -import { randomBytes, randomUUID } from "node:crypto"; -import { once } from "node:events"; -import { rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type { Server } from "node:http"; -import type { AddressInfo } from "node:net"; -import { Readable } from "node:stream"; -import { afterEach, beforeEach, describe, it } from "node:test"; -import { - type InputType, - brotliDecompress as brotliDecompressAsync, - gunzip as gunzipAsync, - inflate as inflateAsync, -} from "node:zlib"; - -import { Binden, BindenError, Context, Middleware } from "binden"; -import { request } from "undici"; - -import { - Compression, - DefaultCompression, - type ICompressFormats, -} from "../index.js"; - -function brotliDecompress(data: InputType): Promise { - return new Promise((resolve, reject) => { - brotliDecompressAsync(data, (error, result) => { - if (error) { - reject(error); - } else { - resolve(result); - } - }); - }); -} - -function gunzip(data: InputType): Promise { - return new Promise((resolve, reject) => { - gunzipAsync(data, (error, result) => { - if (error) { - reject(error); - } else { - resolve(result); - } - }); - }); -} - -function inflate(data: InputType): Promise { - return new Promise((resolve, reject) => { - inflateAsync(data, (error, result) => { - if (error) { - reject(error); - } else { - resolve(result); - } - }); - }); -} - -let url: URL; - -class TestMiddleware extends Middleware { - readonly #data: Buffer | string | undefined; - public constructor(data?: Buffer | string) { - super(); - this.#data = data; - } - public run(ct: unknown): Promise { - ok(ct instanceof Context); - return ct.send(this.#data); - } -} - -describe("Compression", () => { - let app: Binden; - let server: Server; - - beforeEach(async () => { - await new Promise((resolve) => { - app = new Binden(); - server = app.createServer().listen(0, () => { - const { port } = server.address() as AddressInfo; - url = new URL(`http://localhost:${port}`); - resolve(); - }); - }); - }); - - it("constructor (with default options)", async () => { - const expected = "Hello World"; - const format = "deflate"; - app.use(new Compression(), new TestMiddleware(expected)); - - const headers = { "Accept-Encoding": format }; - const response = await request(url, { headers }); - - deepStrictEqual(response.headers["content-encoding"], format); - deepStrictEqual(response.statusCode, 200); - - const body = await response.body.arrayBuffer(); - const actual = (await inflate(body)).toString(); - - deepStrictEqual(actual, expected); - }); - - it("auto (x-gzip)", async () => { - const expected = "Hello World"; - app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); - - const headers = { "Accept-Encoding": "x-gzip" }; - const response = await request(url, { headers }); - - deepStrictEqual(response.headers["content-encoding"], "gzip"); - deepStrictEqual(response.statusCode, 200); - - const body = await response.body.arrayBuffer(); - const actual = (await gunzip(body)).toString(); - - deepStrictEqual(actual, expected); - }); - - it("auto (gzip)", async () => { - const expected = "Hello World"; - const format = "gzip"; - app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); - - const headers = { "Accept-Encoding": format }; - const response = await request(url, { headers }); - - deepStrictEqual(response.headers["content-encoding"], format); - deepStrictEqual(response.statusCode, 200); - - const body = await response.body.arrayBuffer(); - const actual = (await gunzip(body)).toString(); - - deepStrictEqual(actual, expected); - }); - - it("auto (br)", async () => { - const expected = "Hello World"; - const format = "br"; - app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); - - const headers = { "Accept-Encoding": format }; - const response = await request(url, { headers }); - - deepStrictEqual(response.headers["content-encoding"], format); - deepStrictEqual(response.statusCode, 200); - - const body = await response.body.arrayBuffer(); - const actual = (await brotliDecompress(body)).toString(); - - deepStrictEqual(actual, expected); - }); - - it("auto (identity)", async () => { - const expected = "Hello World"; - const format = "* ;q= 0.6, identity ;q= 0.7"; - app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); - - const headers = { "Accept-Encoding": format }; - const response = await request(url, { headers }); - - ok(typeof response.headers["content-encoding"] === "undefined"); - deepStrictEqual(response.statusCode, 200); - - const body = await response.body.arrayBuffer(); - const actual = Buffer.from(body).toString(); - - deepStrictEqual(actual, expected); - }); - - it("auto (*)", async () => { - const expected = "Hello World"; - const format = "* ;q= 0.8, identity ;q= 0.7"; - app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); - - const headers = { "Accept-Encoding": format }; - const response = await request(url, { headers }); - - deepStrictEqual(response.headers["content-encoding"], DefaultCompression); - deepStrictEqual(response.statusCode, 200); - - const body = await response.body.arrayBuffer(); - const actual = (await brotliDecompress(body)).toString(); - - deepStrictEqual(actual, expected); - }); - - it("auto (Default compression when no `Accept-Encoding` header is present)", async () => { - const expected = "Hello World"; - app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); - - const response = await request(url); - - deepStrictEqual(response.headers["content-encoding"], DefaultCompression); - deepStrictEqual(response.statusCode, 200); - - const body = await response.body.arrayBuffer(); - const actual = (await brotliDecompress(body)).toString(); - - deepStrictEqual(actual, expected); - }); - - it("auto (empty `Accept-Encoding` asks for no coding)", async () => { - const expected = "Hello World"; - app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); - - const headers = { "Accept-Encoding": "" }; - const response = await request(url, { headers }); - - ok(typeof response.headers["content-encoding"] === "undefined"); - deepStrictEqual(response.headers.vary, "Accept-Encoding"); - deepStrictEqual(await response.body.text(), expected); - }); - - it("auto (codings refused with `q=0`)", async () => { - const expected = "Hello World"; - app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); - - const headers = { "Accept-Encoding": "gzip;q=0, br;q=0, deflate;q=0" }; - const response = await request(url, { headers }); - - ok(typeof response.headers["content-encoding"] === "undefined"); - deepStrictEqual(await response.body.text(), expected); - }); - - it("auto (`*` skips codings refused with `q=0`)", async () => { - const expected = "Hello World"; - app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); - - const headers = { "Accept-Encoding": "br;q=0, *" }; - const response = await request(url, { headers }); - - deepStrictEqual(response.headers["content-encoding"], "gzip"); - - const body = await response.body.arrayBuffer(); - const actual = (await gunzip(Buffer.from(body))).toString(); - - deepStrictEqual(actual, expected); - }); - - it("auto (`*` with every coding refused)", async () => { - const expected = "Hello World"; - app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); - - const headers = { "Accept-Encoding": "br;q=0, gzip;q=0, deflate;q=0, *" }; - const response = await request(url, { headers }); - - ok(typeof response.headers["content-encoding"] === "undefined"); - deepStrictEqual(await response.body.text(), expected); - }); - - it("auto (adds `Accept-Encoding` to the existing `Vary`)", async () => { - const expected = "Hello World"; - app.use( - (ct: Context) => { - ct.response.setHeader("Vary", "Origin"); - }, - new Compression({ format: "auto" }), - new TestMiddleware(expected), - ); - - const headers = { "Accept-Encoding": "gzip" }; - const response = await request(url, { headers }); - - deepStrictEqual(response.headers.vary, "Origin, Accept-Encoding"); - deepStrictEqual(response.headers["content-encoding"], "gzip"); - }); - - it("auto (keeps a `Vary` that already covers `Accept-Encoding`)", async () => { - const expected = "Hello World"; - app.use( - (ct: Context) => { - ct.response.setHeader("Vary", ["Origin", "accept-encoding"]); - }, - new Compression({ format: "auto" }), - new TestMiddleware(expected), - ); - - const headers = { "Accept-Encoding": "gzip" }; - const response = await request(url, { headers }); - - deepStrictEqual(response.headers.vary, ["Origin", "accept-encoding"]); - }); - - it("does not add `Vary` for a fixed format", async () => { - const expected = "Hello World"; - app.use(new Compression({ format: "gzip" }), new TestMiddleware(expected)); - - const response = await request(url); - - ok(typeof response.headers.vary === "undefined"); - deepStrictEqual(response.headers["content-encoding"], "gzip"); - }); - - it("auto (an explicit quality takes precedence over `*`)", async () => { - const expected = "Hello World"; - app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); - - const headers = { "Accept-Encoding": "br;q=0.1, *;q=1" }; - const response = await request(url, { headers }); - - deepStrictEqual(response.headers["content-encoding"], "gzip"); - - const body = await response.body.arrayBuffer(); - const actual = (await gunzip(Buffer.from(body))).toString(); - - deepStrictEqual(actual, expected); - }); - - it("auto (a weight that is not a qvalue drops its coding)", async () => { - const expected = "Hello World"; - app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); - - const headers = { "Accept-Encoding": "br;q=1, gzip;q=2" }; - const response = await request(url, { headers }); - - deepStrictEqual(response.headers["content-encoding"], "br"); - - const body = await response.body.arrayBuffer(); - deepStrictEqual((await brotliDecompress(body)).toString(), expected); - }); - - it("auto (case-insensitive codings)", async () => { - const expected = "Hello World"; - app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); - - const headers = { "Accept-Encoding": "GZIP;q=1, identity;q=0" }; - const response = await request(url, { headers }); - - deepStrictEqual(response.headers["content-encoding"], "gzip"); - - const body = await response.body.arrayBuffer(); - const actual = (await gunzip(Buffer.from(body))).toString(); - - deepStrictEqual(actual, expected); - }); - - it("auto (a single coding with a low quality)", async () => { - const expected = "Hello World"; - app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); - - const headers = { "Accept-Encoding": "gzip;q=0.5" }; - const response = await request(url, { headers }); - - deepStrictEqual(response.headers["content-encoding"], "gzip"); - - const body = await response.body.arrayBuffer(); - const actual = (await gunzip(Buffer.from(body))).toString(); - - deepStrictEqual(actual, expected); - }); - - it("auto (no acceptable coding falls back to no compression)", async () => { - const expected = "Hello World"; - app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); - - const headers = { "Accept-Encoding": "identity;q=0, *;q=0" }; - const response = await request(url, { headers }); - - ok(typeof response.headers["content-encoding"] === "undefined"); - deepStrictEqual(await response.body.text(), expected); - }); - - it("auto (unsupported)", async () => { - const expected = "Hello World"; - const format = "compress"; - app.use(new Compression({ format: "auto" }), new TestMiddleware(expected)); - - const headers = { "Accept-Encoding": format }; - const response = await request(url, { headers }); - - ok(typeof response.headers["content-encoding"] === "undefined"); - deepStrictEqual(response.statusCode, 200); - - const body = await response.body.arrayBuffer(); - const actual = Buffer.from(body).toString(); - - deepStrictEqual(actual, expected); - }); - - it("gzip", async () => { - const expected = "Hello World"; - const format = "gzip"; - app.use(new Compression({ format }), new TestMiddleware(expected)); - - const response = await request(url); - - deepStrictEqual(response.headers["content-encoding"], format); - deepStrictEqual(response.statusCode, 200); - - const raw = await response.body.arrayBuffer(); - const actual = (await gunzip(raw)).toString(); - - deepStrictEqual(actual, expected); - }); - - it("br", async () => { - const expected = "Hello World"; - const format = "br"; - app.use(new Compression({ format }), new TestMiddleware(expected)); - - const response = await request(url); - - deepStrictEqual(response.headers["content-encoding"], format); - deepStrictEqual(response.statusCode, 200); - - const raw = await response.body.arrayBuffer(); - const actual = (await brotliDecompress(raw)).toString(); - - deepStrictEqual(actual, expected); - }); - - it("deflate", async () => { - const expected = "Hello World"; - const format = "deflate"; - app.use(new Compression({ format }), new TestMiddleware(expected)); - - const response = await request(url); - - deepStrictEqual(response.headers["content-encoding"], format); - deepStrictEqual(response.statusCode, 200); - - const raw = await response.body.arrayBuffer(); - const actual = (await inflate(raw)).toString(); - - deepStrictEqual(actual, expected); - }); - - it("Multiple compressions", async () => { - const expected = "Hello World"; - const formats: readonly ICompressFormats[] = ["gzip", "br", "deflate"]; - app.use( - ...formats.map((format) => new Compression({ format })), - new TestMiddleware(expected), - ); - - const response = await request(url); - - deepStrictEqual( - response.headers["content-encoding"], - [...formats].reverse(), - ); - deepStrictEqual(response.statusCode, 200); - - const body = await response.body.arrayBuffer(); - const gzip = await gunzip(Buffer.from(body)); - const br = await brotliDecompress(gzip); - const actual = (await inflate(br)).toString(); - - deepStrictEqual(actual, expected); - }); - - it( - "sendFile (drops the `Content-Length` of the uncompressed file)", - { timeout: 10_000 }, - async () => { - const expected = randomBytes(1024).toString("hex"); - const path = join(tmpdir(), `binden-compression-${randomUUID()}.txt`); - await writeFile(path, expected); - - try { - app.use(new Compression({ format: "gzip" }), (ct: Context) => - ct.sendFile(path), - ); - - const response = await request(url); - - deepStrictEqual(response.statusCode, 200); - deepStrictEqual(response.headers["content-encoding"], "gzip"); - ok(typeof response.headers["content-length"] === "undefined"); - - const body = await response.body.arrayBuffer(); - const actual = (await gunzip(Buffer.from(body))).toString(); - - deepStrictEqual(actual, expected); - } finally { - await rm(path); - } - }, - ); - - it("weakens the strong `ETag` of a compressed response", async () => { - const body = "Some text to compress. ".repeat(16); - app.use(new Compression({ format: "auto" }), (ct: Context) => { - const { pathname } = ct.url; - ct.setHeader( - "ETag", - pathname === "/weak" ? `W/"same-tag"` : `"same-tag"`, - ); - return ct.send(body); - }); - const etag = async ( - path: string, - encoding: string, - method = "GET", - ): Promise => { - const response = await request(new URL(path, url), { - method, - headers: { "Accept-Encoding": encoding }, - }); - await response.body.text(); - return response.headers.etag; - }; - - // The representations differ: they do not share a strong validator - deepStrictEqual(await etag("/", "identity"), `"same-tag"`); - deepStrictEqual(await etag("/", "gzip"), `W/"same-tag"`); - deepStrictEqual(await etag("/", "gzip", "HEAD"), `W/"same-tag"`); - deepStrictEqual(await etag("/weak", "gzip"), `W/"same-tag"`); - }); - - it( - "does not compress partial responses (`Range`)", - { timeout: 10_000 }, - async () => { - const expected = randomBytes(1024).toString("hex"); - const path = join(tmpdir(), `binden-compression-${randomUUID()}.txt`); - await writeFile(path, expected); - - try { - app.use(new Compression({ format: "gzip" }), (ct: Context) => - ct.sendFile(path), - ); - - const partial = await request(url, { - headers: { Range: "bytes=0-9" }, - }); - - deepStrictEqual(partial.statusCode, 206); - ok(typeof partial.headers["content-encoding"] === "undefined"); - deepStrictEqual(partial.headers["content-length"], "10"); - deepStrictEqual(partial.headers["content-range"], "bytes 0-9/2048"); - deepStrictEqual(await partial.body.text(), expected.slice(0, 10)); - - const unsatisfiable = await request(url, { - headers: { Range: "bytes=4096-" }, - }); - - deepStrictEqual(unsatisfiable.statusCode, 416); - ok(typeof unsatisfiable.headers["content-encoding"] === "undefined"); - deepStrictEqual(unsatisfiable.headers["content-range"], "bytes */2048"); - deepStrictEqual(await unsatisfiable.body.text(), ""); - } finally { - await rm(path); - } - }, - ); - - it("does not compress a response without a body", async () => { - // Node.js rejects any body of these responses with this option - const strict = new Binden() - .use(new Compression({ format: "gzip" }), (ct: Context) => - ct.status(Number(ct.request.headers["x-status"])).send("ignored"), - ) - .createServer({ rejectNonStandardBodyWrites: true }) - .listen(0); - await once(strict, "listening"); - const local = `http://localhost:${(strict.address() as AddressInfo).port}`; - - try { - for (const status of [204, 304]) { - const response = await request(local, { - headers: { "x-status": `${status}` }, - signal: AbortSignal.timeout(5000), - }); - deepStrictEqual(response.statusCode, status); - ok(!("content-encoding" in response.headers), `${status}`); - deepStrictEqual(await response.body.text(), ""); - } - - // The headers of the compressed response, without a body - const head = await request(local, { - method: "HEAD", - headers: { "x-status": "200" }, - signal: AbortSignal.timeout(5000), - }); - deepStrictEqual(head.statusCode, 200); - deepStrictEqual(head.headers["content-encoding"], "gzip"); - ok(!("content-length" in head.headers)); - deepStrictEqual(await head.body.text(), ""); - } finally { - strict.closeAllConnections(); - strict.close(); - } - }); - - it("does not compress `206` without `Content-Range` (multipart/byteranges)", async () => { - const expected = "--boundary--"; - app.use(new Compression({ format: "gzip" }), (ct: Context) => - ct - .status(206) - .setHeader("Content-Type", "multipart/byteranges; boundary=boundary") - .send(expected), - ); - - const response = await request(url); - - deepStrictEqual(response.statusCode, 206); - ok(typeof response.headers["content-encoding"] === "undefined"); - deepStrictEqual(await response.body.text(), expected); - }); - - it("compresses error responses of the following middlewares", async () => { - const message = "invalid request"; - app.use(new Compression({ format: "gzip" }), () => { - throw new BindenError(400, { expose: true, message }); - }); - - const response = await request(url); - - deepStrictEqual(response.statusCode, 400); - deepStrictEqual(response.headers["content-encoding"], "gzip"); - - const body = await response.body.arrayBuffer(); - const actual = (await gunzip(Buffer.from(body))).toString(); - - deepStrictEqual(actual, message); - }); - - it("cuts the response on an error thrown after the first write", async () => { - app.use(new Compression({ format: "gzip" }), (ct: Context) => { - ct.response.write("prefix "); - throw new BindenError(400, { expose: true, message: "invalid request" }); - }); - - // The connection is closed (with the headers or before them): a truncated - // body must not look complete - await rejects(async () => { - const response = await request(url); - await response.body.arrayBuffer(); - }); - }); - - it( - "keeps the backpressure of `response.write()`", - { timeout: 10_000 }, - async () => { - // Compressible data: the compressor fills up, the response does not - const expected = Buffer.alloc(1024 * 1024, "binden "); - const size = 64 * 1024; - const results: unknown[] = []; - app.use(new Compression({ format: "gzip" }), async (ct: Context) => { - for (let offset = 0; offset < expected.length; offset += size) { - const written = ct.response.write( - expected.subarray(offset, offset + size), - ); - results.push(written); - if (!written) { - await once(ct.response, "drain"); - } - } - await new Promise((resolve) => { - ct.response.end(resolve); - }); - }); - - const response = await request(url); - const body = Buffer.from(await response.body.arrayBuffer()); - - deepStrictEqual(await gunzip(body), expected); - ok(results.every((result) => typeof result === "boolean")); - ok(results.includes(false)); - }, - ); - - it( - "keeps the backpressure of `response.write()` (slow client)", - { timeout: 10_000 }, - async () => { - const expected = randomBytes(1024 * 1024); - const size = 64 * 1024; - const results: unknown[] = []; - app.use(new Compression({ format: "gzip" }), async (ct: Context) => { - for (let offset = 0; offset < expected.length; offset += size) { - const written = ct.response.write( - expected.subarray(offset, offset + size), - ); - results.push(written); - if (!written) { - await once(ct.response, "drain"); - } - } - await new Promise((resolve) => { - ct.response.end(resolve); - }); - }); - - const response = await request(url); - // A slow client fills the response, so the compressor has to wait as well - await new Promise((resolve) => { - setTimeout(resolve, 200); - }); - const body = Buffer.from(await response.body.arrayBuffer()); - - deepStrictEqual(await gunzip(body), expected); - ok(results.every((result) => typeof result === "boolean")); - ok(results.includes(false)); - }, - ); - - it("finishes the response before `send()` resolves (an error thrown afterwards)", async () => { - const events: string[] = []; - app.use(new Compression(), async (ct: Context) => { - ct.response.once("finish", () => events.push("finish")); - await ct.send("ok"); - events.push("resolved"); - throw new BindenError(400, { expose: true, message: "late error" }); - }); - - const headers = { "Accept-Encoding": "br" }; - const response = await request(url, { headers }); - - deepStrictEqual(response.statusCode, 200); - const body = await response.body.arrayBuffer(); - deepStrictEqual((await brotliDecompress(body)).toString(), "ok"); - deepStrictEqual(events, ["finish", "resolved"]); - }); - - it("calls the `end()` callback once the response has finished", async () => { - app.use(new Compression({ format: "gzip" }), (ct: Context) => { - ct.response.end("ok", () => { - ct.response.destroy(); - }); - ct.done = true; - }); - - const response = await request(url); - const body = await response.body.arrayBuffer(); - - deepStrictEqual((await gunzip(Buffer.from(body))).toString(), "ok"); - }); - - it("ignores a second `end()`", async () => { - app.use(new Compression({ format: "gzip" }), (ct: Context) => { - ct.response.end("first"); - ct.response.end("second"); - ct.done = true; - }); - - const response = await request(url); - const body = await response.body.arrayBuffer(); - - deepStrictEqual((await gunzip(Buffer.from(body))).toString(), "first"); - }); - - it("reports `writableEnded` once `end()` has been called", async () => { - const states: boolean[] = []; - app.use(new Compression({ format: "gzip" }), async (ct: Context) => { - ct.done = true; - states.push(ct.response.writableEnded); - ct.response.end("first"); - states.push(ct.response.writableEnded); - // As for an uncompressed response, `send()` does nothing after `end()` - await ct.response.send(Readable.from(["second"])); - }); - - const response = await request(url); - const body = await response.body.arrayBuffer(); - - deepStrictEqual((await gunzip(Buffer.from(body))).toString(), "first"); - deepStrictEqual(states, [false, true]); - }); - - it( - "rejects `send()` when the client goes away", - { timeout: 10_000 }, - async () => { - let settle: (code: unknown) => void; - const settled = new Promise((resolve) => { - settle = resolve; - }); - app.use(new Compression({ format: "gzip" }), async (ct: Context) => { - try { - await ct.send(randomBytes(16 * 1024 * 1024)); - settle(null); - } catch (error: unknown) { - settle((error as { code?: unknown }).code); - } - }); - - const response = await request(url); - response.body.destroy(); - - deepStrictEqual(await settled, "ERR_STREAM_PREMATURE_CLOSE"); - }, - ); - - it( - "calls an `end()` callback after the response has finished", - { timeout: 5000 }, - async () => { - const code = new Promise((resolve) => { - app.use(new Compression({ format: "gzip" }), async (ct: Context) => { - await ct.send("ok"); - // As Node.js, with `ERR_STREAM_ALREADY_FINISHED` - ct.response.end((error?: (Error & { code?: unknown }) | null) => { - resolve(error?.code); - }); - }); - }); - - const response = await request(url); - const body = await response.body.arrayBuffer(); - - deepStrictEqual((await gunzip(Buffer.from(body))).toString(), "ok"); - deepStrictEqual(await code, "ERR_STREAM_ALREADY_FINISHED"); - }, - ); - - it("keeps a response started by compressed data (`done`)", async () => { - app.use(new Compression({ format: "gzip" }), (ct: Context) => { - // Waits in the compressor: the headers are not sent yet - ct.response.write("x"); - ct.done = true; - setTimeout(() => { - ct.response.end("y"); - }, 50); - }); - - const response = await request(url); - const body = await response.body.arrayBuffer(); - - deepStrictEqual((await gunzip(Buffer.from(body))).toString(), "xy"); - }); - - it("leaves a response whose headers have been sent before it runs", async () => { - const expected = "Hello World"; - app.use( - (ct: Context) => { - ct.response.flushHeaders(); - }, - new Compression({ format: "auto" }), - (ct: Context) => ct.send(expected), - ); - - const response = await request(url, { - headers: { "Accept-Encoding": "gzip" }, - signal: AbortSignal.timeout(5000), - }); - - deepStrictEqual(response.statusCode, 200); - ok(typeof response.headers["content-encoding"] === "undefined"); - ok(typeof response.headers.vary === "undefined"); - deepStrictEqual(await response.body.text(), expected); - }); - - it("does not compress when the headers have already been sent", async () => { - const expected = "Hello World"; - app.use(new Compression({ format: "gzip" }), (ct: Context) => { - ct.response.flushHeaders(); - ct.response.write("Hello "); - return ct.send("World"); - }); - - const response = await request(url); - - deepStrictEqual(response.statusCode, 200); - ok(typeof response.headers["content-encoding"] === "undefined"); - deepStrictEqual(await response.body.text(), expected); - }); - - afterEach(async () => { - await new Promise((resolve, reject) => { - server.closeIdleConnections(); - setTimeout(() => { - server.close((error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - }, 5); - }); - }); -}); diff --git a/packages/compression/test/conditional.test.ts b/packages/compression/test/conditional.test.ts new file mode 100644 index 00000000..328fe54c --- /dev/null +++ b/packages/compression/test/conditional.test.ts @@ -0,0 +1,283 @@ +import { BindenAbortError, type Context } from "binden"; +import { + type IReceived, + type IServed, + fetchRaw, + none, + serve, + text, + within, +} from "./helpers.js"; +import { type IncomingMessage, request } from "node:http"; +import { after, afterEach, before, suite, test } from "node:test"; +import { deepStrictEqual, ok } from "node:assert"; +import { randomBytes, randomUUID } from "node:crypto"; +import { rm, writeFile } from "node:fs/promises"; +import { Compression } from "../index.js"; +import { join } from "node:path"; +import { once } from "node:events"; +import { tmpdir } from "node:os"; + +/** The headers that tell the representation of a response */ +function representation({ + status, + headers, +}: IReceived): Record { + return { + status, + etag: headers.etag, + vary: headers.vary, + encoding: headers["content-encoding"], + length: headers["content-length"], + ranges: headers["accept-ranges"], + }; +} + +const large = text(2048); +const accepts = { + gzip: { "Accept-Encoding": "gzip" }, + identity: { "Accept-Encoding": "identity" }, + none: {}, + refused: { "Accept-Encoding": "identity;q=0" }, +} as const; + +suite("Conditional requests of compressed responses", () => { + let served: IServed | null = null; + const file = join(tmpdir(), `binden-compression-${randomUUID()}.txt`); + const content = text(4096); + + before(async () => { + await writeFile(file, content); + }); + + after(async () => { + await rm(file); + }); + + afterEach(async () => { + const current = served; + served = null; + await current?.close(); + }); + + test("the dynamic `ETag`: `If-None-Match` sees the representation of `GET`", async () => { + served = await serve( + { etag: true }, + new Compression(), + (context: Context) => { + const bodies: Record = { + "/send": large, + "/buffer": Buffer.from("small"), + "/number": 42, + "/bigint": 42n, + }; + const { pathname } = context.url; + const data = bodies[pathname]; + return pathname === "/small" + ? context.text("small") + : typeof data === "undefined" + ? context.json({ large }) + : context.send(data); + }, + ); + const { url } = served; + + for (const path of [ + "/", + "/send", + "/small", + "/buffer", + "/number", + "/bigint", + ]) { + for (const [name, accept] of Object.entries(accepts)) { + const get = await fetchRaw(url, { path, headers: accept }); + const head = await fetchRaw(url, { + path, + method: "HEAD", + headers: accept, + }); + deepStrictEqual( + representation(head), + representation(get), + `${path} ${name}`, + ); + const etag = String(get.headers.etag); + + for (const method of ["GET", "HEAD"]) { + const conditional = await fetchRaw(url, { + path, + method, + headers: { ...accept, "If-None-Match": etag }, + }); + const expected = + get.status === 406 + ? representation(get) + : { + ...representation(get), + status: 304, + encoding: none, + length: none, + }; + deepStrictEqual( + representation(conditional), + expected, + `${method} ${path} ${name}`, + ); + deepStrictEqual(conditional.raw.length, 0); + } + } + } + + // The tags of the representations + const gzip = await fetchRaw(url, { headers: accepts.gzip }); + const identity = await fetchRaw(url, { headers: accepts.identity }); + deepStrictEqual(gzip.headers.etag, `W/${String(identity.headers.etag)}`); + deepStrictEqual(gzip.headers["content-encoding"], "gzip"); + deepStrictEqual(JSON.parse(gzip.body), { large }); + + // The tag of one matches the other (by the weak comparison) + const cross = await fetchRaw(url, { + headers: { + ...accepts.gzip, + "If-None-Match": String(identity.headers.etag), + }, + }); + deepStrictEqual(cross.status, 304); + deepStrictEqual(cross.headers.etag, gzip.headers.etag); + + // Too small to be compressed, with whatever the request accepts + const small = await fetchRaw(url, { + path: "/small", + headers: accepts.refused, + }); + deepStrictEqual(small.status, 200); + deepStrictEqual(small.body, "small"); + ok(!("vary" in small.headers)); + + const refused = await fetchRaw(url, { headers: accepts.refused }); + deepStrictEqual(refused.status, 406); + ok(!("etag" in refused.headers)); + }); + + test("`sendFile()`: `If-None-Match`, `Range` and `If-Range`", async () => { + served = await serve({}, new Compression(), (context: Context) => { + const validator = context.request.headers["x-validator"]; + return context.response.sendFile( + file, + typeof validator === "string" ? { validator } : {}, + ); + }); + const { url } = served; + + for (const validator of [null, `"v1"`]) { + const tagged = validator === null ? {} : { "x-validator": validator }; + for (const [name, accept] of Object.entries(accepts)) { + const headers = { ...tagged, ...accept }; + const get = await fetchRaw(url, { headers }); + const head = await fetchRaw(url, { method: "HEAD", headers }); + deepStrictEqual(representation(head), representation(get), name); + // Nothing acceptable: no tag to match + deepStrictEqual(get.status === 406, name === "refused"); + if (get.status !== 406) { + deepStrictEqual(get.body, content); + const conditional = await fetchRaw(url, { + headers: { ...headers, "If-None-Match": String(get.headers.etag) }, + }); + deepStrictEqual(representation(conditional), { + ...representation(get), + status: 304, + encoding: none, + length: none, + }); + } + } + + const gzip = await fetchRaw(url, { + headers: { ...tagged, ...accepts.gzip }, + }); + deepStrictEqual(gzip.headers["content-encoding"], "gzip"); + ok(String(gzip.headers.etag).startsWith("W/")); + ok(!("accept-ranges" in gzip.headers)); + const identity = await fetchRaw(url, { + headers: { ...tagged, ...accepts.identity }, + }); + deepStrictEqual(identity.headers["accept-ranges"], "bytes"); + deepStrictEqual(identity.headers["content-length"], `${content.length}`); + + // A range of the file as it is, whatever the request accepts + const range = { ...tagged, ...accepts.gzip, Range: "bytes=0-9" }; + const partial = await fetchRaw(url, { headers: range }); + deepStrictEqual(partial.status, 206); + ok(!("content-encoding" in partial.headers)); + deepStrictEqual(partial.raw.toString(), content.slice(0, 10)); + + // `If-Range` matches the strong tag of the file as it is only + const strong = await fetchRaw(url, { + headers: { ...range, "If-Range": String(identity.headers.etag) }, + }); + deepStrictEqual(strong.status, validator === null ? 200 : 206); + const weak = await fetchRaw(url, { + headers: { ...range, "If-Range": String(gzip.headers.etag) }, + }); + deepStrictEqual(weak.status, 200); + deepStrictEqual(weak.headers["content-encoding"], "gzip"); + deepStrictEqual(weak.body, content); + } + }); + + test( + "a client that goes away cancels a compressed response", + { timeout: 15_000 }, + async () => { + const random = join(tmpdir(), `binden-compression-${randomUUID()}.txt`); + await writeFile(random, randomBytes(8 * 1024 * 1024)); + const { promise: outcome, resolve } = Promise.withResolvers(); + try { + served = await serve( + {}, + new Compression(), + async (context: Context) => { + const cleaned = new Promise((done) => { + context.onCleanup(done); + }); + const sent = await context.response.sendFile(random).then( + () => null, + (error: unknown) => (error as { code?: unknown }).code, + ); + await cleaned; + const { reason } = context.signal as { reason?: unknown }; + resolve([ + sent, + reason instanceof BindenAbortError ? reason.reason : null, + context.response.writableFinished, + ]); + }, + ); + const { url } = served; + + const sent = request(url, { + agent: false, + headers: accepts.gzip, + signal: AbortSignal.timeout(5000), + }).end(); + const [response] = (await once(sent, "response")) as [IncomingMessage]; + deepStrictEqual(response.headers["content-encoding"], "gzip"); + await once(response, "data"); + sent.destroy(); + + deepStrictEqual(await within(outcome), [ + "ERR_STREAM_PREMATURE_CLOSE", + "client_closed", + false, + ]); + + // The server goes on + const next = await fetchRaw(url, { headers: accepts.gzip }); + deepStrictEqual(next.status, 200); + } finally { + await rm(random); + } + }, + ); +}); diff --git a/packages/compression/test/decision.test.ts b/packages/compression/test/decision.test.ts new file mode 100644 index 00000000..2d091a63 --- /dev/null +++ b/packages/compression/test/decision.test.ts @@ -0,0 +1,479 @@ +import { BindenResponse, type Context, Cookie } from "binden"; +import { type IServed, fetchRaw, serve, text } from "./helpers.js"; +import { afterEach, suite, test } from "node:test"; +import compression, { + Compression, + type ICompressionOptions, + skipCompression, +} from "../index.js"; +import { deepStrictEqual, ok, throws } from "node:assert"; +import { Readable } from "node:stream"; +import { ServerResponse } from "node:http"; +import { connect } from "node:net"; + +const body = text(2048); +const gzip = { "Accept-Encoding": "gzip" }; + +suite("The decision at the header commit", () => { + let served: IServed | null = null; + + /** Serves a handler through a compression (`gzip` by default) */ + const start = async ( + handler: (context: Context) => Promise | void, + options: ICompressionOptions = { format: "gzip" }, + strict = false, + ): Promise => { + served = await serve( + { strict }, + new Compression(options), + (context: Context) => handler(context), + ); + return served.url; + }; + + afterEach(async () => { + const current = served; + served = null; + await current?.close(); + }); + + test("the default instance negotiates", async () => { + served = await serve({}, compression, (context: Context) => + context.text(body), + ); + const response = await fetchRaw(served.url, { headers: gzip }); + + deepStrictEqual(compression.format, "auto"); + deepStrictEqual(response.headers["content-encoding"], "gzip"); + deepStrictEqual(response.body, body); + }); + + test("a compressed response: its length and ranges removed, its `ETag` weakened", async () => { + const url = await start((context) => { + const weak = context.url.pathname === "/weak"; + context.setHeader("ETag", weak ? `W/"tag"` : `"tag"`); + context.setHeader("Accept-Ranges", "bytes"); + return context.text(body); + }); + for (const method of ["GET", "HEAD"]) { + for (const path of ["/", "/weak"]) { + const response = await fetchRaw(url, { method, path }); + deepStrictEqual(response.status, 200); + deepStrictEqual(response.headers["content-encoding"], "gzip"); + deepStrictEqual(response.headers.etag, `W/"tag"`); + ok(!("content-length" in response.headers), `${method} ${path}`); + ok(!("accept-ranges" in response.headers)); + deepStrictEqual(response.body, method === "HEAD" ? "" : body); + } + } + }); + + test("the status and the headers of the commit decide (e.g. set after the compression ran)", async () => { + const url = await start(async (context) => { + await Promise.resolve(); + if (context.url.pathname === "/partial") { + context.status(206).setHeader("Content-Range", "bytes 0-2047/4096"); + } else if (context.url.pathname === "/range") { + context.status(416).setHeader("Content-Range", "bytes */4096"); + } + return context.text(body); + }); + + const whole = await fetchRaw(url); + deepStrictEqual(whole.headers["content-encoding"], "gzip"); + + for (const path of ["/partial", "/range"]) { + const response = await fetchRaw(url, { path }); + ok(!("content-encoding" in response.headers), path); + deepStrictEqual(response.headers["content-length"], `${body.length}`); + deepStrictEqual(response.raw.toString(), body); + } + }); + + test("responses without a body are sent as they are", async () => { + const url = await start( + (context) => { + const status = Number(context.request.headers["x-status"]); + context.setHeader("ETag", `"tag"`).status(status); + if (context.url.pathname === "/write") { + context.done = true; + context.response.writeHead(status, { "Content-Type": "text/plain" }); + context.response.end(); + return Promise.resolve(); + } + const empty = status === 200 || status === 205; + return context.response.send(empty ? "" : "ignored"); + }, + { format: "gzip" }, + true, + ); + for (const status of [204, 205, 304]) { + for (const path of ["/", "/write"]) { + const response = await fetchRaw(url, { + path, + headers: { "x-status": `${status}` }, + }); + deepStrictEqual(response.status, status); + ok(!("content-encoding" in response.headers), `${status} ${path}`); + deepStrictEqual(response.raw.length, 0); + } + } + + // An empty body is not compressed + const empty = await fetchRaw(url, { headers: { "x-status": "200" } }); + ok(!("content-encoding" in empty.headers)); + deepStrictEqual(empty.headers["content-length"], "0"); + deepStrictEqual(empty.headers.etag, `"tag"`); + }); + + test("an encoded response is sent as it is, `identity` is removed", async () => { + const url = await start((context) => { + const encoding = context.request.headers["x-encoding"]; + if (typeof encoding === "string") { + context.setHeader("Content-Encoding", encoding.split(",")); + } + return context.text(context.url.pathname === "/small" ? "small" : body); + }); + + // Also from a response that is not compressed + const small = await fetchRaw(url, { + path: "/small", + headers: { "x-encoding": "identity" }, + }); + ok(!("content-encoding" in small.headers)); + deepStrictEqual(small.body, "small"); + + const precompressed = await fetchRaw(url, { + headers: { "x-encoding": "br" }, + }); + deepStrictEqual(precompressed.headers["content-encoding"], "br"); + deepStrictEqual(precompressed.raw.toString(), body); + + for (const encoding of ["identity", "IDENTITY, identity"]) { + const response = await fetchRaw(url, { + headers: { "x-encoding": encoding }, + }); + deepStrictEqual(response.headers["content-encoding"], "gzip", encoding); + deepStrictEqual(response.body, body); + } + + // An empty value is not `identity` + const empty = await fetchRaw(url, { headers: { "x-encoding": "" } }); + deepStrictEqual(empty.headers["content-encoding"], ""); + deepStrictEqual(empty.raw.toString(), body); + }); + + test("`Cache-Control: no-transform` is sent as it is", async () => { + const url = await start((context) => { + const value = context.request.headers["x-cache-control"] as string; + context.setHeader("Cache-Control", value.split("|")); + return context.text(body); + }); + for (const value of [ + "no-transform", + "public, No-Transform", + "max-age=0|no-transform", + ]) { + const response = await fetchRaw(url, { + headers: { "x-cache-control": value }, + }); + ok(!("content-encoding" in response.headers), value); + deepStrictEqual(response.raw.toString(), body); + } + const other = await fetchRaw(url, { + headers: { "x-cache-control": "no-store" }, + }); + deepStrictEqual(other.headers["content-encoding"], "gzip"); + }); + + test("`skipCompression()` leaves one response uncompressed", async () => { + const url = await start(async (context) => { + const { pathname } = context.url; + if (pathname === "/skip") { + skipCompression(context.response); + } else if (pathname === "/late") { + context.done = true; + context.response.setHeader("Content-Type", "text/plain"); + context.response.write(body.slice(0, 1024)); + skipCompression(context.response); + await new Promise((resolve) => { + context.response.end(body.slice(1024), resolve); + }); + return; + } + await context.text(body); + }); + + const skipped = await fetchRaw(url, { path: "/skip" }); + ok(!("content-encoding" in skipped.headers)); + deepStrictEqual(skipped.raw.toString(), body); + + // Once the headers are committed, it no longer changes the response + const late = await fetchRaw(url, { path: "/late" }); + deepStrictEqual(late.headers["content-encoding"], "gzip"); + deepStrictEqual(late.body, body); + + const other = await fetchRaw(url); + deepStrictEqual(other.headers["content-encoding"], "gzip"); + + throws(() => { + skipCompression({} as BindenResponse); + }, new TypeError("The response is not a `BindenResponse`")); + }); + + test("a fixed format does not bypass the conditions", async () => { + const url = await start((context) => { + if (context.url.pathname === "/no-transform") { + context.setHeader("Cache-Control", "no-transform"); + } + return context.url.pathname === "/small" + ? context.text("small") + : context.text(body); + }); + for (const path of ["/small", "/no-transform"]) { + const response = await fetchRaw(url, { path }); + ok(!("content-encoding" in response.headers), path); + } + }); + + test("an installation is idempotent", async () => { + const first = new Compression({ format: "gzip" }); + served = await serve( + {}, + first, + first, + // Would decide first (its methods replace those of the first one) + new Compression({ format: "br" }), + (context: Context) => context.text(body), + ); + const response = await fetchRaw(served.url); + + deepStrictEqual(response.headers["content-encoding"], "gzip"); + deepStrictEqual(response.body, body); + }); + + test("`206` is sent as it is, also without `Content-Range`", async () => { + const expected = "--boundary--"; + const url = await start( + (context) => + context + .status(206) + .setHeader("Content-Type", "multipart/byteranges; boundary=boundary") + .send(expected), + { format: "gzip", threshold: 0, filter: (): boolean => true }, + ); + const response = await fetchRaw(url); + + deepStrictEqual(response.status, 206); + ok(!("content-encoding" in response.headers)); + deepStrictEqual(response.raw.toString(), expected); + }); + + test("a response whose headers have been sent is left as it is", async () => { + const replaced: string[] = []; + served = await serve( + {}, + (context: Context) => { + context.response.flushHeaders(); + }, + new Compression(), + (context: Context) => { + // None of its methods has been replaced + const names = ["writeHead", "write", "end", "flush", "send"]; + replaced.push( + ...names.filter((name) => Object.hasOwn(context.response, name)), + ); + return context.send(body); + }, + ); + const response = await fetchRaw(served.url, { headers: gzip }); + + deepStrictEqual(response.status, 200); + ok(!("content-encoding" in response.headers)); + ok(!("vary" in response.headers)); + deepStrictEqual(response.raw.toString(), body); + deepStrictEqual(replaced, []); + }); + + test("`HEAD` gets the headers of `GET`, also for a stream", async () => { + const url = await start((context) => + context + .setHeader("Content-Type", "text/plain") + .send(Readable.from([body.slice(0, 1024), body.slice(1024)])), + ); + for (const method of ["GET", "HEAD"]) { + const response = await fetchRaw(url, { method }); + deepStrictEqual(response.headers["content-encoding"], "gzip", method); + ok(!("content-length" in response.headers)); + deepStrictEqual(response.body, method === "GET" ? body : ""); + } + }); + + test("an informational status is sent as it is", async () => { + const url = await start((context) => { + context.done = true; + context.response.writeHead(102, { "Content-Type": "text/plain" }); + context.response.end(); + }); + const { port } = new URL(url); + const socket = connect(Number(port), "localhost"); + socket.end( + "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ); + let head = ""; + for await (const chunk of socket) { + head += (chunk as Buffer).toString(); + if (head.includes("\r\n\r\n")) { + break; + } + } + socket.destroy(); + + ok(head.startsWith("HTTP/1.1 102 Processing\r\n"), head); + ok(!/content-encoding/iu.test(head), head); + }); + + test("`flushHeaders()` commits the decision", async () => { + const url = await start(async (context) => { + context.done = true; + context.response.setHeader("Content-Type", "text/plain"); + context.response.flushHeaders(); + // Too late to change the representation + throws(() => context.setHeader("X-Late", "1"), { + code: "ERR_HTTP_HEADERS_SENT", + }); + await new Promise((resolve) => { + context.response.end(body, resolve); + }); + }); + const response = await fetchRaw(url); + + deepStrictEqual(response.headers["content-encoding"], "gzip"); + deepStrictEqual(response.body, body); + }); + + test("`writeHead()` with its headers commits the decision", async () => { + const url = await start(async (context) => { + const { response } = context; + const { pathname } = context.url; + context.done = true; + response.setHeader("X-Before", "1"); + if (pathname === "/object") { + response.writeHead(200, "Fine", { + "Content-Type": "text/plain", + "Content-Length": body.length, + "": "ignored", + }); + } else if (pathname === "/flat") { + response.writeHead(201, ["Content-Type", "text/plain", "", "ignored"]); + } else if (pathname === "/pairs") { + response.writeHead(200, [ + ["Content-Type", "text/plain"], + ["X-Before", "2"], + ["X-Before", "3"], + ]); + } else if (pathname === "/image") { + response.writeHead(200, { "Content-Type": "image/png" }); + } else { + response.writeHead(200); + } + await new Promise((resolve) => { + response.end(body, resolve); + }); + }); + + const object = await fetchRaw(url, { path: "/object" }); + deepStrictEqual(object.status, 200); + deepStrictEqual(object.headers["content-encoding"], "gzip"); + ok(!("content-length" in object.headers)); + deepStrictEqual(object.body, body); + + const flat = await fetchRaw(url, { path: "/flat" }); + deepStrictEqual(flat.status, 201); + deepStrictEqual(flat.headers["content-encoding"], "gzip"); + deepStrictEqual(flat.body, body); + + const pairs = await fetchRaw(url, { path: "/pairs" }); + deepStrictEqual(pairs.headers["content-encoding"], "gzip"); + deepStrictEqual(pairs.headers["x-before"], "2, 3"); + deepStrictEqual(pairs.body, body); + + // Not compressible, or no `Content-Type` + for (const path of ["/image", "/none"]) { + const response = await fetchRaw(url, { path }); + ok(!("content-encoding" in response.headers), path); + deepStrictEqual(response.raw.toString(), body); + } + }); + + test("`writeHead()` rejects what Node.js rejects", async () => { + const errors: unknown[] = []; + const url = await start((context) => { + const { response } = context; + for (const args of [ + [99], + [1000], + ["abc"], + [200, ["Content-Type"]], + ] as const) { + try { + Reflect.apply(response.writeHead.bind(response), response, args); + } catch (error: unknown) { + errors.push((error as { code?: unknown }).code); + } + } + return context.text(body); + }); + const response = await fetchRaw(url); + + deepStrictEqual(errors, [ + "ERR_HTTP_INVALID_STATUS_CODE", + "ERR_HTTP_INVALID_STATUS_CODE", + "ERR_HTTP_INVALID_STATUS_CODE", + "ERR_INVALID_ARG_VALUE", + ]); + deepStrictEqual(response.headers["content-encoding"], "gzip"); + deepStrictEqual(response.body, body); + }); + + test("headers committed around the compression are left as they are", async () => { + const url = await start(async (context) => { + const { response } = context; + context.done = true; + response.setHeader("Content-Type", "text/plain"); + // As Node.js itself would, once the headers are sent + ServerResponse.prototype.writeHead.call(response, 200); + throws(() => response.writeHead(200), { code: "ERR_HTTP_HEADERS_SENT" }); + await new Promise((resolve) => { + response.end(body, resolve); + }); + }); + const response = await fetchRaw(url); + + ok(!("content-encoding" in response.headers)); + deepStrictEqual(response.raw.toString(), body); + }); + + test("cookies are set by the commit of a compressed response", async () => { + const cookie = new Cookie({ key: "a", value: "1" }); + const url = await start((context) => { + context.response.cookies.add(cookie); + return context.text(body); + }); + const response = await fetchRaw(url); + + deepStrictEqual(response.headers["content-encoding"], "gzip"); + deepStrictEqual(response.headers["set-cookie"], [cookie.toString()]); + }); + + test("error responses are compressed like the others", async () => { + const url = await start(() => { + throw Object.assign(new Error(body), { status: 400, expose: true }); + }); + const response = await fetchRaw(url); + + deepStrictEqual(response.status, 400); + deepStrictEqual(response.headers["content-encoding"], "gzip"); + deepStrictEqual(response.body, body); + }); +}); diff --git a/packages/compression/test/filter.test.ts b/packages/compression/test/filter.test.ts new file mode 100644 index 00000000..f28191c5 --- /dev/null +++ b/packages/compression/test/filter.test.ts @@ -0,0 +1,264 @@ +import { + Compression, + type ICompressionOptions, + isCompressible, +} from "../index.js"; +import { ContentType, type Context } from "binden"; +import { + type IServed, + capture, + fetchRaw, + none, + serve, + text, +} from "./helpers.js"; +import { afterEach, suite, test } from "node:test"; +import { deepStrictEqual, ok } from "node:assert"; + +const gzip = { "Accept-Encoding": "gzip" }; + +suite("Threshold and filter", () => { + let served: IServed | null = null; + + afterEach(async () => { + const current = served; + served = null; + await current?.close(); + }); + + /** Serves a handler through a compression */ + const start = async ( + handler: (context: Context) => Promise | void, + options?: ICompressionOptions, + ): Promise => { + served = await serve({}, new Compression(options), (context: Context) => + handler(context), + ); + return served.url; + }; + + /** Ends the response with a body (without `Content-Length`) */ + const end = async (context: Context, body: string): Promise => { + context.done = true; + await new Promise((resolve) => { + context.response.end(body, resolve); + }); + }; + + test("`threshold` (1024 by default) compares the `Content-Length`", async () => { + const url = await start((context) => + context.text(text(Number(context.request.headers["x-size"]))), + ); + for (const [size, coding] of [ + [1023, none], + [1024, "gzip"], + ] as const) { + const response = await fetchRaw(url, { + headers: { ...gzip, "x-size": `${size}` }, + }); + deepStrictEqual(response.headers["content-encoding"], coding, `${size}`); + deepStrictEqual(response.body, text(size)); + } + + // Not compressed for its size: the request does not matter + const small = await fetchRaw(url, { headers: { ...gzip, "x-size": "10" } }); + deepStrictEqual(small.headers["content-length"], "10"); + ok(!("vary" in small.headers)); + }); + + test("`threshold` is set by the option", async () => { + let url = await start((context) => context.text("tiny"), { threshold: 0 }); + deepStrictEqual( + (await fetchRaw(url, { headers: gzip })).headers["content-encoding"], + "gzip", + ); + await served?.close(); + + url = await start((context) => context.text(text(2048)), { + threshold: 4096, + }); + ok( + !("content-encoding" in (await fetchRaw(url, { headers: gzip })).headers), + ); + }); + + test("a body without a length is compressed, the chunk of `end()` is the body", async () => { + const url = await start(async (context) => { + const { response } = context; + response.setHeader("Content-Type", "text/plain"); + const { pathname } = context.url; + if (pathname === "/stream") { + response.write("tiny"); + await end(context, ""); + } else { + await end(context, pathname === "/large" ? text(2048) : "tiny"); + } + }); + + const stream = await fetchRaw(url, { path: "/stream", headers: gzip }); + deepStrictEqual(stream.headers["content-encoding"], "gzip"); + deepStrictEqual(stream.body, "tiny"); + + const small = await fetchRaw(url, { headers: gzip }); + ok(!("content-encoding" in small.headers)); + deepStrictEqual(small.headers["content-length"], "4"); + + const large = await fetchRaw(url, { path: "/large", headers: gzip }); + deepStrictEqual(large.headers["content-encoding"], "gzip"); + deepStrictEqual(large.body, text(2048)); + }); + + test("isCompressible()", () => { + const cases: [string | null, boolean][] = [ + ["text/plain", true], + ["text/html; charset=utf-8", true], + ["text/css", true], + ["text/csv", true], + ["text/event-stream", false], + ["application/json", true], + ["application/problem+json", true], + ["application/ld+json", true], + ["application/javascript", true], + ["application/xml", true], + ["application/atom+xml", true], + ["image/svg+xml", true], + ["application/wasm", true], + ["application/x-www-form-urlencoded", true], + ["font/ttf", true], + ["image/x-icon", true], + ["image/png", false], + ["image/jpeg", false], + ["font/woff2", false], + ["application/octet-stream", false], + ["application/zip", false], + ["video/mp4", false], + ["application/jsonx", false], + [null, false], + ]; + for (const [type, expected] of cases) { + const parsed = type === null ? null : ContentType.fromString(type); + deepStrictEqual(isCompressible(parsed), expected, `${type}`); + } + // A media type in another case + deepStrictEqual( + isCompressible(new ContentType({ type: "Image/SVG+XML" })), + true, + ); + deepStrictEqual( + isCompressible(new ContentType({ type: "TEXT/Event-Stream" })), + false, + ); + }); + + test("the default filter decides by the `Content-Type`", async () => { + const url = await start((context) => { + const type = context.request.headers["x-type"]; + if (typeof type === "string") { + context.setHeader("Content-Type", type); + } + return context.response.send(Buffer.from(text(2048))); + }); + const cases: [string | null, string | undefined][] = [ + ["text/html; charset=utf-8", "gzip"], + ["application/geo+json", "gzip"], + ["image/png", none], + ["text/event-stream", none], + ["not a media type", none], + [null, "gzip"], + ]; + for (const [type, coding] of cases) { + const response = await fetchRaw(url, { + headers: type === null ? gzip : { ...gzip, "x-type": type }, + }); + deepStrictEqual(response.headers["content-encoding"], coding, `${type}`); + deepStrictEqual(response.body, text(2048)); + } + }); + + test("a custom filter gets the context and the `Content-Type`", async () => { + const seen: unknown[] = []; + const url = await start( + (context) => { + context.setHeader("Content-Type", "image/bmp; x=1"); + return context.response.send(Buffer.from(text(2048))); + }, + { + filter: (context, type): boolean => { + seen.push(context.url.pathname, type?.toString()); + return context.url.pathname !== "/no"; + }, + }, + ); + + const yes = await fetchRaw(url, { path: "/yes", headers: gzip }); + deepStrictEqual(yes.headers["content-encoding"], "gzip"); + const no = await fetchRaw(url, { path: "/no", headers: gzip }); + ok(!("content-encoding" in no.headers)); + deepStrictEqual(seen, ["/yes", "image/bmp; x=1", "/no", "image/bmp; x=1"]); + }); + + test("a filter that does not return a boolean replaces the response with 500", async () => { + const results: Record = { + "/undefined": none, + "/string": "true", + "/number": 1, + "/promise": Promise.resolve(true), + "/null": null, + }; + const captured = capture(); + const callbacks: string[] = []; + served = await serve( + { app: { logger: captured.logger } }, + new Compression({ + filter: (context): boolean => { + const { pathname } = context.url; + if (pathname === "/throws") { + throw new Error("filter"); + } + return results[pathname] as boolean; + }, + }), + async (context: Context) => { + const { response } = context; + context.done = true; + response.setHeader("Content-Type", "text/plain"); + response.setHeader("ETag", `"tag"`); + // Discarded once the response has been replaced + response.write(text(2048), () => callbacks.push("write")); + response.write("no callback"); + await new Promise((resolve) => { + response.end("end", () => { + callbacks.push("end"); + resolve(); + }); + }); + }, + ); + + for (const path of [...Object.keys(results), "/throws"]) { + const response = await fetchRaw(served.url, { path, headers: gzip }); + deepStrictEqual(response.status, 500, path); + deepStrictEqual(response.headers["content-length"], "0"); + ok(!("content-type" in response.headers)); + ok(!("etag" in response.headers)); + ok(!("vary" in response.headers)); + deepStrictEqual(response.raw.length, 0); + } + // Every callback has run + deepStrictEqual( + callbacks.toSorted((a, b) => a.localeCompare(b)), + [ + ...Array.from({ length: 6 }, () => "end"), + ...Array.from({ length: 6 }, () => "write"), + ], + ); + await captured.waitFor(6); + deepStrictEqual(captured.records, [ + ...Array.from({ length: 5 }, () => ({ + level: 50, + msg: "The `filter` of the compression has not returned a boolean", + })), + { level: 50, msg: "The `filter` of the compression has failed" }, + ]); + }); +}); diff --git a/packages/compression/test/fixtures/filter-rejection.ts b/packages/compression/test/fixtures/filter-rejection.ts new file mode 100644 index 00000000..99277c10 --- /dev/null +++ b/packages/compression/test/fixtures/filter-rejection.ts @@ -0,0 +1,59 @@ +// Run by `process.test.ts`: a `filter` that returns a rejected promise (or a +// thenable whose `then` throws) must not crash the process +// (`unhandledRejection`): the response is replaced with 500 +import { Binden, type Context } from "binden"; +import { type IncomingMessage, request } from "node:http"; +import type { AddressInfo } from "node:net"; +import { Compression } from "../../index.js"; +import { Logger } from "@binden/logger"; +import { once } from "node:events"; + +process.on("unhandledRejection", () => { + process.stderr.write("unhandledRejection\n"); + process.exitCode = 1; +}); +setTimeout(() => { + process.stderr.write("Timed out\n"); + process.exit(2); +}, 10_000).unref(); + +const thenable = { + get then(): never { + throw new Error("A throwing `then`"); + }, +}; +const filter = (context: Context): boolean => + (context.url.pathname === "/thenable" + ? thenable + : Promise.reject(new Error("A rejecting filter"))) as unknown as boolean; + +const app = new Binden({ logger: new Logger({ level: "silent" }) }).use( + new Compression({ filter }), + (context: Context) => context.text("Some text to compress. ".repeat(64)), +); + +const server = app.createServer().listen(0); +await once(server, "listening"); +const { port } = server.address() as AddressInfo; + +const statuses: number[] = []; +for (const path of ["/", "/thenable"]) { + const response = await new Promise((resolve, failed) => { + request( + { agent: false, path, port, headers: { "Accept-Encoding": "gzip" } }, + resolve, + ) + .on("error", failed) + .end(); + }); + response.resume(); + await once(response, "end"); + statuses.push(response.statusCode ?? 0); +} + +// Time for a rejection to be reported +await new Promise((resolve) => { + setTimeout(resolve, 50); +}); +server.close(); +process.stdout.write(`${statuses.join(" ")}\n`); diff --git a/packages/compression/test/helpers.ts b/packages/compression/test/helpers.ts new file mode 100644 index 00000000..c1afdda5 --- /dev/null +++ b/packages/compression/test/helpers.ts @@ -0,0 +1,226 @@ +import { + Binden, + BindenResponse, + type IBindenOptions, + type IMiddleware, + type IResponseOptions, + validateResponseOptions, +} from "binden"; +import { + type IncomingHttpHeaders, + type IncomingMessage, + type OutgoingHttpHeaders, + type Server, + createServer, + request, +} from "node:http"; +import type { AddressInfo } from "node:net"; +import { Logger } from "@binden/logger"; +import { once } from "node:events"; +import zlib from "node:zlib"; + +/** Resolves after `ms` milliseconds */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** The result of a promise, which must settle within `ms` milliseconds */ +export async function within(promise: Promise, ms = 5000): Promise { + const { promise: expired, reject } = Promise.withResolvers(); + const timer = setTimeout(() => { + reject(new Error(`Not settled within ${ms} ms`)); + }, ms); + try { + return await Promise.race([promise, expired]); + } finally { + clearTimeout(timer); + } +} + +/** + * Asserts that two buffers have the same bytes (without the diff of large + * buffers, which takes the time of a hang) + */ +export function sameBytes(actual: Buffer, expected: Buffer): void { + if (!actual.equals(expected)) { + throw new Error( + `Different bytes: ${actual.length} bytes instead of ${expected.length}`, + ); + } +} + +/** A compressible body of `size` bytes */ +export function text(size: number): string { + return "Some text to compress. ".repeat(Math.ceil(size / 23)).slice(0, size); +} + +/** `undefined`, which the linter does not let the tests write */ +export const none = ({} as { value?: undefined }).value; + +const decoders = new Map Buffer>([ + ["br", (body): Buffer => zlib.brotliDecompressSync(body)], + ["deflate", (body): Buffer => zlib.inflateSync(body)], + ["gzip", (body): Buffer => zlib.gunzipSync(body)], + ["zstd", (body): Buffer => zlib.zstdDecompressSync(body)], +]); + +/** Decompresses a body of a content coding (none without one) */ +export function decode( + coding: string[] | string | undefined, + body: Buffer, +): Buffer { + if (typeof coding === "undefined") { + return body; + } + const decoder = decoders.get(String(coding)); + if (!decoder) { + throw new Error(`Unexpected coding: ${String(coding)}`); + } + return decoder(body); +} + +/** A response with the dynamic `ETag` of binden */ +export class EtagResponse extends BindenResponse { + // eslint-disable-next-line class-methods-use-this -- the options of a subclass + protected override get options(): Readonly { + return validateResponseOptions({ etag: true }); + } +} + +/** A logger whose records are kept */ +export interface ICaptured { + readonly logger: Logger; + /** The messages logged so far, with their level */ + readonly records: { level: unknown; msg: unknown }[]; + /** Resolves once `count` records have been written (within 5 s) */ + waitFor(count: number): Promise; +} + +/** A `Logger` whose records are kept */ +export function capture(): ICaptured { + const lines: string[] = []; + const logger = new Logger({ + destination: { + write(line: string): void { + lines.push(line); + }, + }, + level: "debug", + }); + return { + logger, + get records(): { level: unknown; msg: unknown }[] { + return lines.map((line) => { + const { level, msg } = JSON.parse(line) as Record; + return { level, msg }; + }); + }, + waitFor: async (count: number): Promise => { + const deadline = Date.now() + 5000; + while (lines.length < count) { + if (Date.now() > deadline) { + throw new Error(`${lines.length} records: ${lines.join("")}`); + } + await sleep(5); + } + }, + }; +} + +/** A listening server of an application */ +export interface IServed { + readonly server: Server; + readonly url: string; + close(): Promise; +} + +/** The options of `serve()` */ +export interface IServeOptions { + /** The options of the application */ + readonly app?: IBindenOptions | undefined; + /** Responses with the dynamic `ETag` */ + readonly etag?: boolean | undefined; + /** `rejectNonStandardBodyWrites` of the server */ + readonly strict?: boolean | undefined; +} + +/** Serves the middlewares on a free port */ +export async function serve( + { app: options = {}, etag = false, strict = false }: IServeOptions, + ...middlewares: IMiddleware[] +): Promise { + const app = new Binden(options).use(...middlewares); + const server = createServer({ + ...app.serverOptions(), + ...(etag + ? { ServerResponse: EtagResponse as unknown as typeof BindenResponse } + : {}), + rejectNonStandardBodyWrites: strict, + }); + server.on("request", app.callback(server)); + server.listen(0); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + return { + server, + url: `http://localhost:${port}`, + close: async (): Promise => { + server.closeAllConnections(); + server.close(); + await once(server, "close"); + }, + }; +} + +/** A response, as received */ +export interface IReceived { + readonly status: number; + readonly headers: IncomingHttpHeaders; + /** The body as received */ + readonly raw: Buffer; + /** The body, decompressed by its `Content-Encoding` (empty without a body) */ + readonly body: string; +} + +/** The options of a request */ +export interface IRequestOptions { + readonly path?: string | undefined; + readonly method?: string | undefined; + readonly headers?: OutgoingHttpHeaders | undefined; +} + +/** Sends a request (on a new connection), and reads its response within 5 s */ +export function fetchRaw( + url: string, + { path = "/", method = "GET", headers = {} }: IRequestOptions = {}, +): Promise { + return within( + new Promise((resolve, reject) => { + const sent = request( + new URL(path, url), + { agent: false, headers, method, signal: AbortSignal.timeout(5000) }, + (response: IncomingMessage) => { + const chunks: Buffer[] = []; + response + .on("data", (chunk: Buffer) => chunks.push(chunk)) + .once("end", () => { + const raw = Buffer.concat(chunks); + const coding = response.headers["content-encoding"]; + resolve({ + status: response.statusCode ?? 0, + headers: response.headers, + raw, + get body(): string { + return raw.length === 0 ? "" : decode(coding, raw).toString(); + }, + }); + }) + .once("error", reject); + }, + ); + sent.once("error", reject).end(); + }), + ); +} diff --git a/packages/compression/test/negotiation.test.ts b/packages/compression/test/negotiation.test.ts new file mode 100644 index 00000000..e5eb0d2d --- /dev/null +++ b/packages/compression/test/negotiation.test.ts @@ -0,0 +1,214 @@ +import { Compression, type ICompressionOptions } from "../index.js"; +import { type IServed, fetchRaw, none, serve, text } from "./helpers.js"; +import { afterEach, suite, test } from "node:test"; +import { deepStrictEqual, ok } from "node:assert"; +import type { Context } from "binden"; +import { negotiate } from "../src/negotiate.js"; + +const body = text(2048); + +suite("Content negotiation", () => { + let served: IServed | null = null; + + /** Serves `body` (as text) through a compression */ + const start = async (options?: ICompressionOptions): Promise => { + served = await serve({}, new Compression(options), (context: Context) => + context.text(body), + ); + return served.url; + }; + + afterEach(async () => { + const current = served; + served = null; + await current?.close(); + }); + + test("negotiate()", () => { + const offers = ["br", "gzip", "deflate"] as const; + const cases: [string | undefined, string | false][] = [ + // No header (or an empty one): no coding + [none, "identity"], + ["", "identity"], + // The greatest weight, then the order of the offers + ["gzip", "gzip"], + ["x-gzip", "gzip"], + ["GZIP;Q=0.5", "gzip"], + ["gzip, deflate, br, zstd", "br"], + ["deflate;q=0.9, gzip;q=0.8", "deflate"], + ["br;q=0.1, *", "gzip"], + ["br;q=0, *", "gzip"], + ["*;q=0.5, br;q=0.5", "br"], + // A weight that is not a qvalue drops its element + ["br;q=1, gzip;q=2", "br"], + ["gzip;q=2", "identity"], + // Unknown codings are acceptable alternatives to nothing + ["compress", "identity"], + // `identity` wins only when it weighs more + ["*;q=0.6, identity;q=0.7", "identity"], + ["*;q=0.8, identity;q=0.7", "br"], + ["gzip;q=0.5, identity;q=0.5", "gzip"], + ["identity", "identity"], + // Nothing is acceptable: `identity` is excluded + ["identity;q=0", false], + ["*;q=0", false], + ["identity;q=0, *;q=0", false], + ["br;q=0, gzip;q=0, deflate;q=0, identity;q=0", false], + ["compress, identity;q=0", false], + // `identity` named by itself is acceptable whatever `*` is + ["*;q=0, identity", "identity"], + ["*;q=0, gzip", "gzip"], + ["br;q=0, gzip;q=0, deflate;q=0, *", "identity"], + ]; + for (const [header, expected] of cases) { + deepStrictEqual(negotiate(header, offers), expected, header); + } + deepStrictEqual(negotiate("gzip, br", ["gzip", "br"]), "gzip"); + deepStrictEqual(negotiate("zstd", offers), "identity"); + deepStrictEqual(negotiate("zstd, br", ["zstd", "br"]), "zstd"); + }); + + test("no `Accept-Encoding` gets no coding (and `Vary`)", async () => { + const url = await start(); + const response = await fetchRaw(url); + + deepStrictEqual(response.status, 200); + ok(!("content-encoding" in response.headers)); + deepStrictEqual(response.headers.vary, "Accept-Encoding"); + deepStrictEqual(response.headers["content-length"], `${body.length}`); + deepStrictEqual(response.body, body); + }); + + test("the coding of the greatest weight, in the order of `encodings`", async () => { + const url = await start(); + const cases: [string, string | undefined][] = [ + ["gzip, deflate, br, zstd", "br"], + ["gzip, deflate", "gzip"], + ["deflate", "deflate"], + ["x-gzip", "gzip"], + ["br;q=0.5, gzip", "gzip"], + ["zstd", none], + ["identity", none], + ["*;q=0.6, identity;q=0.7", none], + ]; + for (const [header, coding] of cases) { + const response = await fetchRaw(url, { + headers: { "Accept-Encoding": header }, + }); + deepStrictEqual(response.headers["content-encoding"], coding, header); + deepStrictEqual(response.headers.vary, "Accept-Encoding"); + deepStrictEqual(response.body, body); + } + }); + + test("`encodings` sets the codings and their order (`zstd` is opt-in)", async () => { + const url = await start({ encodings: ["zstd", "gzip"] }); + const cases: [string, string | undefined][] = [ + ["gzip, deflate, br, zstd", "zstd"], + ["br, gzip", "gzip"], + ["br, deflate", none], + ]; + for (const [header, coding] of cases) { + const response = await fetchRaw(url, { + headers: { "Accept-Encoding": header }, + }); + deepStrictEqual(response.headers["content-encoding"], coding, header); + deepStrictEqual(response.body, body); + } + }); + + test("`406 Not Acceptable` when no coding is acceptable, `identity` neither", async () => { + const url = await start(); + for (const header of ["identity;q=0", "*;q=0", "compress, identity;q=0"]) { + for (const method of ["GET", "HEAD"]) { + const response = await fetchRaw(url, { + method, + headers: { "Accept-Encoding": header }, + }); + deepStrictEqual(response.status, 406, `${method} ${header}`); + deepStrictEqual(response.headers["content-length"], "0"); + deepStrictEqual(response.headers.vary, "Accept-Encoding"); + ok(!("content-type" in response.headers)); + ok(!("content-encoding" in response.headers)); + deepStrictEqual(response.body, ""); + } + } + + // An acceptable coding is used rather than `identity` + const gzip = await fetchRaw(url, { + headers: { "Accept-Encoding": "identity;q=0, gzip" }, + }); + deepStrictEqual(gzip.status, 200); + deepStrictEqual(gzip.headers["content-encoding"], "gzip"); + deepStrictEqual(gzip.body, body); + }); + + test("`406` replaces the response at its commit, by every path", async () => { + const callbacks: string[] = []; + served = await serve({}, new Compression(), async (context: Context) => { + const { response } = context; + context.done = true; + const headers = { "Content-Type": "text/plain" }; + if (context.url.pathname === "/flush") { + response.setHeader("Content-Type", "text/plain"); + response.flushHeaders(); + } else { + response.writeHead(200, "Fine", headers); + } + response.write(body, () => callbacks.push("write")); + await new Promise((resolve) => { + response.end(() => { + callbacks.push("end"); + resolve(); + }); + }); + }); + for (const path of ["/head", "/flush"]) { + const response = await fetchRaw(served.url, { + path, + headers: { "Accept-Encoding": "*;q=0" }, + }); + deepStrictEqual(response.status, 406, path); + deepStrictEqual(response.headers["content-length"], "0"); + ok(!("content-type" in response.headers)); + deepStrictEqual(response.raw.length, 0); + } + deepStrictEqual(callbacks.toSorted(), ["end", "end", "write", "write"]); + }); + + test("a fixed format compresses whatever the request accepts, without `Vary`", async () => { + const url = await start({ format: "deflate" }); + for (const header of [null, "gzip", "identity;q=0"]) { + const response = await fetchRaw(url, { + headers: header === null ? {} : { "Accept-Encoding": header }, + }); + deepStrictEqual(response.status, 200); + deepStrictEqual(response.headers["content-encoding"], "deflate"); + ok(!("vary" in response.headers)); + deepStrictEqual(response.body, body); + } + }); + + test("`Vary` keeps its values", async () => { + served = await serve( + {}, + (context: Context) => { + const { pathname } = context.url; + context.setHeader( + "Vary", + pathname === "/list" ? ["Origin", "accept-encoding"] : "Origin", + ); + }, + new Compression(), + (context: Context) => context.text(body), + ); + const headers = { "Accept-Encoding": "gzip" }; + + const added = await fetchRaw(served.url, { headers }); + deepStrictEqual(added.headers.vary, "Origin, Accept-Encoding"); + + const kept = await fetchRaw(served.url, { path: "/list", headers }); + deepStrictEqual(kept.headers.vary, "Origin, accept-encoding"); + deepStrictEqual(kept.headers["content-encoding"], "gzip"); + }); +}); diff --git a/packages/compression/test/options.test.ts b/packages/compression/test/options.test.ts new file mode 100644 index 00000000..89cddbc4 --- /dev/null +++ b/packages/compression/test/options.test.ts @@ -0,0 +1,77 @@ +import { Compression, type ICompressionOptions } from "../index.js"; +import { deepStrictEqual, throws } from "node:assert"; +import { suite, test } from "node:test"; +import zlib from "node:zlib"; + +type IFactory = + | "createBrotliCompress" + | "createDeflate" + | "createGzip" + | "createZstdCompress"; + +/** Replaces a factory of `node:zlib` while `fn` runs */ +async function replacing( + name: IFactory, + value: unknown, + fn: () => Promise | void, +): Promise { + const original = Object.getOwnPropertyDescriptor(zlib, name); + Object.defineProperty(zlib, name, { ...original, value }); + try { + await fn(); + } finally { + Object.defineProperty(zlib, name, original as PropertyDescriptor); + } +} + +const invalid = (options: unknown): ICompressionOptions => + options as ICompressionOptions; + +suite("Options", () => { + test("invalid options throw", () => { + const formats = new TypeError( + "`format` is not one of auto, br, deflate, gzip and zstd", + ); + for (const format of ["compress", "identity", 1, null]) { + throws(() => new Compression(invalid({ format })), formats); + } + throws( + () => new Compression({ format: "gzip", encodings: ["gzip"] }), + new TypeError("`encodings` applies to the `auto` format only"), + ); + const encodings = new TypeError( + "`encodings` is not a non-empty list of distinct codings (br, deflate, gzip, zstd)", + ); + for (const value of [[], ["compress"], ["gzip", "gzip"], "gzip", null]) { + throws(() => new Compression(invalid({ encodings: value })), encodings); + } + throws( + () => new Compression(invalid({ threshold: "1" })), + new TypeError("`threshold` is not a number"), + ); + for (const threshold of [-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { + throws( + () => new Compression({ threshold }), + new RangeError("`threshold` is not a non-negative integer"), + ); + } + throws( + () => new Compression(invalid({ filter: true })), + new TypeError("`filter` is not a function"), + ); + throws(() => new Compression(invalid({ disabled: 1 })), TypeError); + }); + + test("zstd needs its API in `node:zlib`", async () => { + await replacing("createZstdCompress", null, () => { + const error = new TypeError( + "zstd is not supported by this version of Node.js", + ); + throws(() => new Compression({ format: "zstd" }), error); + throws(() => new Compression({ encodings: ["gzip", "zstd"] }), error); + // Not requested + deepStrictEqual(new Compression().format, "auto"); + deepStrictEqual(new Compression({ format: "gzip" }).format, "gzip"); + }); + }); +}); diff --git a/packages/compression/test/process.test.ts b/packages/compression/test/process.test.ts new file mode 100644 index 00000000..fdae8476 --- /dev/null +++ b/packages/compression/test/process.test.ts @@ -0,0 +1,28 @@ +import { suite, test } from "node:test"; +import { deepStrictEqual } from "node:assert"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +/** + * Runs a fixture (`test/fixtures`) in a process of its own: a crash (e.g. an + * unhandled rejection) is its exit code and output, not a failure of the + * test runner + */ +function run(fixture: string): Record { + const { status, stdout, stderr } = spawnSync( + process.execPath, + ["--import", "tsx", join(import.meta.dirname, "fixtures", fixture)], + { encoding: "utf8", timeout: 30_000 }, + ); + return { status, stdout, stderr }; +} + +suite("Process safety", () => { + test("a `filter` that returns a rejected promise does not crash the process", () => { + deepStrictEqual(run("filter-rejection.ts"), { + status: 0, + stdout: "500 500\n", + stderr: "", + }); + }); +}); diff --git a/packages/compression/test/streaming.test.ts b/packages/compression/test/streaming.test.ts new file mode 100644 index 00000000..9ec03b13 --- /dev/null +++ b/packages/compression/test/streaming.test.ts @@ -0,0 +1,327 @@ +import { BindenError, type Context } from "binden"; +import { Compression, type ICompressionOptions } from "../index.js"; +import { + type IServed, + decode, + fetchRaw, + sameBytes, + serve, + sleep, + text, + within, +} from "./helpers.js"; +import { type IncomingMessage, request } from "node:http"; +import { afterEach, suite, test } from "node:test"; +import { deepStrictEqual, ok, rejects } from "node:assert"; +import { Readable } from "node:stream"; +import { once } from "node:events"; +import { randomBytes } from "node:crypto"; +import zlib from "node:zlib"; + +/** The compressors that `createGzip()` of `node:zlib` creates while `fn` runs */ +async function spy( + fn: (created: zlib.Gzip[]) => Promise, +): Promise { + const original = Object.getOwnPropertyDescriptor(zlib, "createGzip"); + const create = original?.value as typeof zlib.createGzip; + const created: zlib.Gzip[] = []; + Object.defineProperty(zlib, "createGzip", { + ...original, + value: (options?: zlib.ZlibOptions): zlib.Gzip => { + const compressor = create(options); + created.push(compressor); + return compressor; + }, + }); + try { + await fn(created); + } finally { + Object.defineProperty(zlib, "createGzip", original as PropertyDescriptor); + } + return created; +} + +/** Sends a request, and resolves with its response (not read) */ +async function respond(url: string): Promise { + const sent = request(url, { + agent: false, + signal: AbortSignal.timeout(5000), + }).end(); + const [response] = (await within(once(sent, "response"))) as [ + IncomingMessage, + ]; + return response; +} + +suite("Streaming through the compressor", () => { + let served: IServed | null = null; + + /** Serves a handler through a compression (`gzip` by default) */ + const start = async ( + handler: (context: Context) => Promise | void, + options: ICompressionOptions = { format: "gzip" }, + ): Promise => { + served = await serve({}, new Compression(options), (context: Context) => { + context.setHeader("Content-Type", "text/plain"); + return handler(context); + }); + return served.url; + }; + + afterEach(async () => { + const current = served; + served = null; + await current?.close(); + }); + + test("the compressor is created for a compressed body only", async () => { + const url = await start( + (context) => + context.text(context.url.pathname === "/small" ? "small" : text(2048)), + { format: "auto" }, + ); + const created = await spy(async () => { + const gzip = { "Accept-Encoding": "gzip" }; + await fetchRaw(url, { path: "/small", headers: gzip }); + await fetchRaw(url, { headers: { "Accept-Encoding": "identity" } }); + await fetchRaw(url, { method: "HEAD", headers: gzip }); + const response = await fetchRaw(url, { headers: gzip }); + deepStrictEqual(response.headers["content-encoding"], "gzip"); + }); + + deepStrictEqual(created.length, 1); + }); + + /** Writes the data in chunks, with backpressure: the results of `write()` */ + const backpressure = async (expected: Buffer): Promise => { + const size = 64 * 1024; + const results: unknown[] = []; + const url = await start(async (context) => { + const { response } = context; + context.done = true; + for (let offset = 0; offset < expected.length; offset += size) { + const written = response.write( + expected.subarray(offset, offset + size), + ); + results.push(written); + if (!written) { + await once(response, "drain"); + } + } + await new Promise((resolve) => { + response.end(resolve); + }); + }); + + const response = await fetchRaw(url); + sameBytes(decode("gzip", response.raw), expected); + ok(results.includes(false)); + }; + + test("keeps the backpressure of `write()` (a full compressor)", async () => { + // Compressible data fills the compressor + await backpressure(Buffer.alloc(1024 * 1024, "binden ")); + }); + + test( + "keeps the backpressure of `write()` (a full response)", + { timeout: 15_000 }, + async () => { + // Random data fills the response + await backpressure(randomBytes(1024 * 1024)); + }, + ); + + test("a slow client pauses the compressor", { timeout: 15_000 }, async () => { + const expected = randomBytes(4 * 1024 * 1024); + let ended = false; + const buffered: number[] = []; + const url = await start(async (context) => { + context.done = true; + const done = new Promise((resolve) => { + context.response.end(expected, () => { + ended = true; + resolve(); + }); + }); + await sleep(100); + buffered.push(context.response.writableLength); + await done; + }); + + const response = await respond(url); + response.pause(); + await sleep(200); + // Paused: the response cannot finish, and does not buffer the data + deepStrictEqual(ended, false); + ok(Number(buffered[0]) < 1024 * 1024, `${buffered[0]}`); + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => chunks.push(chunk)).resume(); + await within(once(response, "end")); + sameBytes(decode("gzip", Buffer.concat(chunks)), expected); + deepStrictEqual(ended, true); + }); + + test("`send()` resolves once the response has finished", async () => { + const events: string[] = []; + const url = await start(async (context) => { + context.response.once("finish", () => events.push("finish")); + await context.send(text(2048)); + events.push("resolved"); + throw new BindenError(400, { expose: true, message: "late error" }); + }); + const response = await fetchRaw(url); + + deepStrictEqual(response.status, 200); + deepStrictEqual(response.body, text(2048)); + deepStrictEqual(events, ["finish", "resolved"]); + }); + + test("`end()` as Node.js: its callback, a second `end()`, `writableEnded`", async () => { + const states: unknown[] = []; + const { promise: code, resolve } = Promise.withResolvers(); + const url = await start(async (context) => { + const { response } = context; + context.done = true; + states.push(response.writableEnded); + response.write("first "); + await new Promise((done) => { + response.end(text(2048), "utf8", () => { + states.push(response.writableFinished); + done(); + }); + states.push(response.writableEnded); + // Ignored + response.end("second"); + }); + // As for an uncompressed response, `send()` does nothing after `end()` + await response.send(Readable.from(["third"])); + // As Node.js, with `ERR_STREAM_ALREADY_FINISHED` + response.end((error?: (Error & { code?: unknown }) | null) => { + resolve(error?.code); + }); + }); + const response = await fetchRaw(url); + + deepStrictEqual(response.body, `first ${text(2048)}`); + deepStrictEqual(states, [false, true, true]); + deepStrictEqual(await within(code), "ERR_STREAM_ALREADY_FINISHED"); + }); + + test("`end()` with a callback only, while the response is not finished", async () => { + const url = await start(async (context) => { + const { response } = context; + context.done = true; + response.write(text(2048)); + response.end(); + await new Promise((resolve) => { + response.end(resolve); + }); + }); + const response = await fetchRaw(url); + + deepStrictEqual(response.body, text(2048)); + }); + + test("a response started by compressed data is kept (`done`)", async () => { + const url = await start((context) => { + // Waits in the compressor + context.response.write("x"); + context.done = true; + setTimeout(() => { + context.response.end("y"); + }, 50); + }); + const response = await fetchRaw(url); + + deepStrictEqual(response.body, "xy"); + }); + + test("an error after the first write cuts the response", async () => { + const url = await start((context) => { + context.response.write("prefix "); + throw new BindenError(400, { expose: true, message: "invalid request" }); + }); + + // A truncated body must not look complete + await rejects(fetchRaw(url)); + }); + + test( + "writes fail once the client has gone away", + { timeout: 15_000 }, + async () => { + const { promise: failed, resolve } = Promise.withResolvers(); + const { promise: written, resolve: started } = + Promise.withResolvers(); + const url = await start((context) => { + context.done = true; + // More than the connection takes: the next write waits + context.response.write(randomBytes(8 * 1024 * 1024)); + context.response.write("next", (error?: Error | null) => { + resolve((error as { code?: unknown } | null | undefined)?.code); + }); + started(null); + }); + + const response = await respond(url); + await within(written); + response.destroy(); + + deepStrictEqual(await within(failed), "ERR_STREAM_DESTROYED"); + }, + ); + + test("a write after `end()` fails, the response is sent", async () => { + const { promise: code, resolve } = Promise.withResolvers(); + const url = await start((context) => { + const { response } = context; + response.end(text(2048)); + response.write("late"); + const written = response.write("late", (error?: Error | null) => { + resolve((error as { code?: unknown } | null | undefined)?.code); + }); + deepStrictEqual(written, false); + context.done = true; + }); + const response = await fetchRaw(url); + + deepStrictEqual(response.body, text(2048)); + deepStrictEqual(await within(code), "ERR_STREAM_WRITE_AFTER_END"); + }); + + test( + "`send()` rejects when the client goes away", + { timeout: 15_000 }, + async () => { + const { promise: settled, resolve } = Promise.withResolvers(); + const url = await start(async (context) => { + try { + await context.send(randomBytes(16 * 1024 * 1024)); + resolve(null); + } catch (error: unknown) { + resolve((error as { code?: unknown }).code); + } + }); + + const response = await respond(url); + response.destroy(); + + deepStrictEqual(await within(settled), "ERR_STREAM_PREMATURE_CLOSE"); + }, + ); + + test("a failure of the compressor closes the connection", async () => { + await spy(async (created) => { + const url = await start(async (context) => { + context.done = true; + context.response.write("data"); + await sleep(20); + // E.g. out of memory (the compressor of the response is the last one) + created.at(-1)?.destroy(new Error("The compressor has failed")); + }); + // At once (not the timeout of the request) + await rejects(fetchRaw(url), { code: "ECONNRESET" }); + }); + }); +}); From e132525e1f89800ddeeeb836fbfcd9b27398d467 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 02:24:46 +0300 Subject: [PATCH 169/675] feat!: add the options of the compressors in @binden/compression The compressors were created with the defaults of `node:zlib` only, and `br` compressed with its quality 11: a quality meant for compressing ahead of time, far too slow for responses compressed as they are sent. The `gzip`, `deflate`, `brotli` and `zstd` options are given to their compressors (copied when the compression is created). They are checked in the constructor, by creating (and closing) a compressor of each coding that the compression may use, so that invalid options throw then, rather than with a request. `br` now has the quality 4 by default, unless `brotli` sets it. BREAKING CHANGE: `br` compresses with the quality 4 by default; set `brotli: { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } }` to keep the former output. Co-Authored-By: Claude Opus 5.5 --- packages/compression/index.ts | 61 +++++++++- packages/compression/src/codings.ts | 29 +++-- packages/compression/src/response.ts | 4 +- packages/compression/test/options.test.ts | 135 +++++++++++++++++++++- 4 files changed, 218 insertions(+), 11 deletions(-) diff --git a/packages/compression/index.ts b/packages/compression/index.ts index 01dd0ecf..15f11fa9 100644 --- a/packages/compression/index.ts +++ b/packages/compression/index.ts @@ -6,8 +6,19 @@ import { Middleware, } from "binden"; import { CompressedResponse, type ISettings, skip } from "./src/response.js"; -import { type ICompressEncodings, codings, hasZstd } from "./src/codings.js"; +import { + type ICompressEncodings, + type ICompressorOptions, + codings, + createCompressor, + hasZstd, +} from "./src/codings.js"; import { type ICompressionFilter, isCompressible } from "./src/filter.js"; +import zlib, { + type BrotliOptions, + type ZlibOptions, + type ZstdOptions, +} from "node:zlib"; export { type ICompressionFilter, isCompressible } from "./src/filter.js"; export type { ICompressEncodings } from "./src/codings.js"; @@ -43,6 +54,14 @@ export interface ICompressionOptions extends IMiddlewareParams { * that replaces the response with `500`. `isCompressible()` by default. */ filter?: ICompressionFilter | undefined; + /** The options of the `gzip` compressor */ + gzip?: ZlibOptions | undefined; + /** The options of the `deflate` compressor */ + deflate?: ZlibOptions | undefined; + /** The options of the `br` compressor (`BROTLI_PARAM_QUALITY` is `4` by default) */ + brotli?: BrotliOptions | undefined; + /** The options of the `zstd` compressor */ + zstd?: ZstdOptions | undefined; } const formats: readonly unknown[] = ["auto", ...codings]; @@ -53,6 +72,16 @@ const defaultEncodings: readonly ICompressEncodings[] = [ "deflate", ]; +/** Throws a `TypeError` unless the option is an object (or not set) */ +function checkObject(name: string, value: unknown): void { + if ( + typeof value !== "undefined" && + (typeof value !== "object" || value === null || Array.isArray(value)) + ) { + throw new TypeError(`\`${name}\` is not an object`); + } +} + /** The codings of `encodings`: a non-empty list of distinct codings */ function checkEncodings(encodings: unknown): readonly ICompressEncodings[] { if ( @@ -79,7 +108,8 @@ export class Compression extends Middleware { /** * Validates the options: a `TypeError` (or `RangeError`) for an invalid one, - * also for `zstd` when `node:zlib` does not have it + * also for the options of a compressor (one is created to check them), or + * for `zstd` when `node:zlib` does not have it */ public constructor(options: ICompressionOptions = {}) { const { @@ -88,6 +118,10 @@ export class Compression extends Middleware { threshold = 1024, filter = (_context: Context, type: ContentType | null): boolean => isCompressible(type), + gzip = {}, + deflate = {}, + brotli = {}, + zstd = {}, ...rest } = options; super(rest); @@ -104,6 +138,11 @@ export class Compression extends Middleware { } else if (typeof filter !== "function") { throw new TypeError("`filter` is not a function"); } + checkObject("gzip", gzip); + checkObject("deflate", deflate); + checkObject("brotli", brotli); + checkObject("zstd", zstd); + const offered = typeof encodings === "undefined" ? defaultEncodings @@ -113,11 +152,29 @@ export class Compression extends Middleware { throw new TypeError("zstd is not supported by this version of Node.js"); } + const compressors: ICompressorOptions = Object.freeze({ + br: { + ...brotli, + params: { + [zlib.constants.BROTLI_PARAM_QUALITY]: 4, + ...brotli.params, + }, + }, + deflate: { ...deflate }, + gzip: { ...gzip }, + zstd: { ...zstd }, + }); + // Invalid options of a compressor throw now, rather than with a request + for (const coding of used) { + createCompressor(coding, compressors).close(); + } + this.#settings = Object.freeze({ format, encodings: offered, threshold, filter, + compressors, }); } diff --git a/packages/compression/src/codings.ts b/packages/compression/src/codings.ts index dd49701d..e406a950 100644 --- a/packages/compression/src/codings.ts +++ b/packages/compression/src/codings.ts @@ -1,5 +1,9 @@ +import zlib, { + type BrotliOptions, + type ZlibOptions, + type ZstdOptions, +} from "node:zlib"; import type { Transform } from "node:stream"; -import zlib from "node:zlib"; /** A content coding that the compression produces */ export type ICompressEncodings = "br" | "deflate" | "gzip" | "zstd"; @@ -12,6 +16,14 @@ export const codings: readonly ICompressEncodings[] = [ "zstd", ]; +/** The options of the compressor of every coding */ +export interface ICompressorOptions { + readonly br: BrotliOptions; + readonly deflate: ZlibOptions; + readonly gzip: ZlibOptions; + readonly zstd: ZstdOptions; +} + /** A compressor of `node:zlib` */ export type ICompressor = Transform & zlib.Zlib; @@ -25,14 +37,17 @@ export function hasZstd(): boolean { ); } -/** A compressor of a coding */ -export function createCompressor(coding: ICompressEncodings): ICompressor { +/** A compressor of a coding, with its options */ +export function createCompressor( + coding: ICompressEncodings, + options: ICompressorOptions, +): ICompressor { if (coding === "br") { - return zlib.createBrotliCompress(); + return zlib.createBrotliCompress(options.br); } else if (coding === "deflate") { - return zlib.createDeflate(); + return zlib.createDeflate(options.deflate); } else if (coding === "gzip") { - return zlib.createGzip(); + return zlib.createGzip(options.gzip); } - return zlib.createZstdCompress(); + return zlib.createZstdCompress(options.zstd); } diff --git a/packages/compression/src/response.ts b/packages/compression/src/response.ts index f516c5de..91aa1d91 100644 --- a/packages/compression/src/response.ts +++ b/packages/compression/src/response.ts @@ -8,6 +8,7 @@ import { import { type ICompressEncodings, type ICompressor, + type ICompressorOptions, createCompressor, } from "./codings.js"; import { @@ -24,6 +25,7 @@ export interface ISettings { readonly encodings: readonly ICompressEncodings[]; readonly threshold: number; readonly filter: ICompressionFilter; + readonly compressors: ICompressorOptions; } /** @@ -536,7 +538,7 @@ export class CompressedResponse { */ #start(coding: ICompressEncodings): ICompressor { const response = this.#response; - const compressor = createCompressor(coding); + const compressor = createCompressor(coding, this.#settings.compressors); compressor .on("data", (chunk: Buffer) => { const accepted = this.#write(chunk); diff --git a/packages/compression/test/options.test.ts b/packages/compression/test/options.test.ts index 89cddbc4..43fa5f78 100644 --- a/packages/compression/test/options.test.ts +++ b/packages/compression/test/options.test.ts @@ -1,6 +1,8 @@ import { Compression, type ICompressionOptions } from "../index.js"; +import { type IServed, fetchRaw, serve, text } from "./helpers.js"; +import { afterEach, suite, test } from "node:test"; import { deepStrictEqual, throws } from "node:assert"; -import { suite, test } from "node:test"; +import type { Context } from "binden"; import zlib from "node:zlib"; type IFactory = @@ -24,10 +26,36 @@ async function replacing( } } +/** The options that a factory of `node:zlib` is given while `fn` runs */ +async function optionsOf( + name: IFactory, + fn: () => Promise, +): Promise { + const create = zlib[name] as (options?: unknown) => unknown; + const given: unknown[] = []; + await replacing( + name, + (options?: unknown): unknown => { + given.push(options); + return create(options); + }, + fn, + ); + return given; +} + const invalid = (options: unknown): ICompressionOptions => options as ICompressionOptions; suite("Options", () => { + let served: IServed | null = null; + + afterEach(async () => { + const current = served; + served = null; + await current?.close(); + }); + test("invalid options throw", () => { const formats = new TypeError( "`format` is not one of auto, br, deflate, gzip and zstd", @@ -59,9 +87,44 @@ suite("Options", () => { () => new Compression(invalid({ filter: true })), new TypeError("`filter` is not a function"), ); + for (const name of ["gzip", "deflate", "brotli", "zstd"]) { + for (const value of [null, 1, []]) { + throws( + () => new Compression(invalid({ [name]: value })), + new TypeError(`\`${name}\` is not an object`), + ); + } + } throws(() => new Compression(invalid({ disabled: 1 })), TypeError); }); + test("the options of the compressors are checked by creating one", () => { + throws(() => new Compression({ format: "gzip", gzip: { level: 99 } }), { + name: "RangeError", + code: "ERR_OUT_OF_RANGE", + }); + throws( + () => new Compression({ format: "deflate", deflate: { level: -2 } }), + { code: "ERR_OUT_OF_RANGE" }, + ); + throws(() => new Compression({ brotli: { params: { 9999: 1 } } }), { + code: "ERR_BROTLI_INVALID_PARAM", + }); + throws( + () => + new Compression({ + encodings: ["zstd"], + zstd: { params: { 9999: 1 } }, + }), + { code: "ERR_ZSTD_INVALID_PARAM" }, + ); + // The options of a coding that is not used + deepStrictEqual( + new Compression({ format: "br", gzip: { level: 99 } }).format, + "br", + ); + }); + test("zstd needs its API in `node:zlib`", async () => { await replacing("createZstdCompress", null, () => { const error = new TypeError( @@ -74,4 +137,74 @@ suite("Options", () => { deepStrictEqual(new Compression({ format: "gzip" }).format, "gzip"); }); }); + + test("the compressors get their options (`br` with the quality 4 by default)", async () => { + const { BROTLI_PARAM_QUALITY, ZSTD_c_compressionLevel } = zlib.constants; + const body = text(2048); + const gzip = { level: 1 }; + served = await serve( + {}, + new Compression({ + encodings: ["br", "gzip", "deflate", "zstd"], + gzip, + deflate: { level: 9 }, + zstd: { params: { [ZSTD_c_compressionLevel]: 10 } }, + }), + (context: Context) => context.text(body), + ); + // Copied: a later change does not apply + gzip.level = 9; + + const request = async (coding: string): Promise => { + const response = await fetchRaw((served as IServed).url, { + headers: { "Accept-Encoding": coding }, + }); + deepStrictEqual(response.headers["content-encoding"], coding); + deepStrictEqual(response.body, body); + }; + deepStrictEqual( + await optionsOf("createBrotliCompress", () => request("br")), + [{ params: { [BROTLI_PARAM_QUALITY]: 4 } }], + ); + deepStrictEqual(await optionsOf("createGzip", () => request("gzip")), [ + { level: 1 }, + ]); + deepStrictEqual( + await optionsOf("createDeflate", () => request("deflate")), + [{ level: 9 }], + ); + deepStrictEqual( + await optionsOf("createZstdCompress", () => request("zstd")), + [{ params: { [ZSTD_c_compressionLevel]: 10 } }], + ); + }); + + test("the options of `br` keep their parameters, and may set the quality", async () => { + const { BROTLI_PARAM_QUALITY, BROTLI_PARAM_MODE } = zlib.constants; + const body = text(2048); + served = await serve( + {}, + new Compression({ + format: "br", + brotli: { + chunkSize: 32 * 1024, + params: { [BROTLI_PARAM_MODE]: 1, [BROTLI_PARAM_QUALITY]: 11 }, + }, + }), + (context: Context) => context.text(body), + ); + const { url } = served; + const given = await optionsOf("createBrotliCompress", async () => { + const response = await fetchRaw(url); + deepStrictEqual(response.headers["content-encoding"], "br"); + deepStrictEqual(response.body, body); + }); + + deepStrictEqual(given, [ + { + chunkSize: 32 * 1024, + params: { [BROTLI_PARAM_MODE]: 1, [BROTLI_PARAM_QUALITY]: 11 }, + }, + ]); + }); }); From 6eedbea377ba88aa4ec93c45eab8008bac67bd72 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 02:25:24 +0300 Subject: [PATCH 170/675] feat: flush the compressor with response.flush() in @binden/compression `flush()` of binden sends the headers, then writes an empty chunk whose callback runs once the chunks before it are flushed. On a compressed response, that chunk only went into the compressor: the data written so far stayed there, and a stream of events (e.g. server-sent events) reached the client only when the compressor filled up or ended. `flush()` of a compressed response now flushes the compressor (`Z_SYNC_FLUSH`, `BROTLI_OPERATION_FLUSH` or `ZSTD_e_flush`), so that the client can decompress everything written before, and resolves once the response has written the flushed bytes, also those that backpressure holds back in the compressor. It rejects when the response is closed or fails before, and keeps the other behaviour of binden (a late send, an ended response). Without a compressor, it is the `flush()` of binden. Event streams, which the default filter does not compress, can be compressed with a `filter` and a `flush()` after each event. Co-Authored-By: Claude Opus 5.5 --- packages/compression/src/codings.ts | 14 + packages/compression/src/response.ts | 71 ++++- packages/compression/test/flush.test.ts | 399 ++++++++++++++++++++++++ 3 files changed, 480 insertions(+), 4 deletions(-) create mode 100644 packages/compression/test/flush.test.ts diff --git a/packages/compression/src/codings.ts b/packages/compression/src/codings.ts index e406a950..58cde73d 100644 --- a/packages/compression/src/codings.ts +++ b/packages/compression/src/codings.ts @@ -51,3 +51,17 @@ export function createCompressor( } return zlib.createZstdCompress(options.zstd); } + +/** + * The flush of a coding that ends its compressed data so far, without + * ending the stream: the client can decompress everything written before + */ +export function flushKind(coding: ICompressEncodings): number { + const { constants } = zlib; + if (coding === "br") { + return constants.BROTLI_OPERATION_FLUSH; + } else if (coding === "zstd") { + return constants.ZSTD_e_flush; + } + return constants.Z_SYNC_FLUSH; +} diff --git a/packages/compression/src/response.ts b/packages/compression/src/response.ts index 91aa1d91..76d328e6 100644 --- a/packages/compression/src/response.ts +++ b/packages/compression/src/response.ts @@ -10,6 +10,7 @@ import { type ICompressor, type ICompressorOptions, createCompressor, + flushKind, } from "./codings.js"; import { type OutgoingHttpHeader, @@ -182,7 +183,7 @@ export function skip(response: object): void { * The compression of one response. Its methods are replaced on the response * itself, so that every writer (the following middlewares, `sendFile()`, the * error handler) goes through it, and the decision is taken once, when the - * headers are committed: by `writeHead()`, `flushHeaders()`, + * headers are committed: by `writeHead()`, `flushHeaders()` (and `flush()`), * or the first `write()` or `end()`. */ export class CompressedResponse { @@ -193,13 +194,23 @@ export class CompressedResponse { readonly #writeHead: IResponse["writeHead"]; readonly #write: IResponse["write"]; readonly #end: IResponse["end"]; + readonly #flush: IResponse["flush"]; readonly #send: IResponse["send"]; #sink: ISink = "pending"; #compressor: ICompressor | null = null; + /** The flush of the coding of the compressor (see `flushKind()`) */ + #kind = 0; /** Whether the application has ended a compressed response */ #ended = false; + /** Set while `flush()` writes its marker (see `#onWrite()`) */ + #flushing = false; /** The size of the body given to `send()`, which a `304` does not have */ #hint: number | null = null; + /** The compressed bytes emitted, and those that the response has flushed */ + #produced = 0; + #written = 0; + /** The flushes that wait for the response to flush the compressed bytes */ + #waiters: { readonly target: number; readonly callback: ICallback }[] = []; private constructor(context: Context, settings: ISettings) { const { response } = context; @@ -209,6 +220,7 @@ export class CompressedResponse { this.#writeHead = response.writeHead.bind(response); this.#write = response.write.bind(response); this.#end = response.end.bind(response); + this.#flush = response.flush.bind(response); this.#send = response.send.bind(response); } @@ -235,6 +247,17 @@ export class CompressedResponse { this.#onWrite(args)) as IResponse["write"]; response.end = ((...args: unknown[]): IResponse => this.#onEnd(args)) as IResponse["end"]; + response.flush = (): Promise => { + // `flush()` of binden writes an empty chunk, whose callback runs once + // the chunks before it are flushed: for a compressed response, it is + // the flush of the compressor (see `#onWrite()`) + this.#flushing = true; + try { + return this.#flush(); + } finally { + this.#flushing = false; + } + }; response.send = (...args: unknown[]): Promise => { if (this.#sink === "pending") { this.#hint = sizeOf(args[0], args[1]); @@ -298,7 +321,12 @@ export class CompressedResponse { ICallback | undefined; if (this.#sink === "compressor") { const compressor = this.#compressor as ICompressor; - if (this.#ended) { + if (this.#flushing) { + // The marker of `flush()`, which always has a callback (see `#hook()`) + this.#flushing = false; + this.#flushCompressor(compressor, callback as ICallback); + return true; + } else if (this.#ended) { // As Node.js, but the response is sent (not destroyed, nor crashing // the process with an `error` event) if (callback) { @@ -506,6 +534,7 @@ export class CompressedResponse { response.setHeader("ETag", `W/${etag}`); } if (!bodiless && this.#context.request.method !== "HEAD") { + this.#kind = flushKind(outcome.coding); this.#compressor = this.#start(outcome.coding); this.#sink = "compressor"; return; @@ -541,7 +570,13 @@ export class CompressedResponse { const compressor = createCompressor(coding, this.#settings.compressors); compressor .on("data", (chunk: Buffer) => { - const accepted = this.#write(chunk); + this.#produced += chunk.length; + const accepted = this.#write(chunk, (error?: Error | null) => { + if (!error) { + this.#written += chunk.length; + this.#release(); + } + }); if (!accepted) { compressor.pause(); response.once("drain", () => { @@ -559,9 +594,37 @@ export class CompressedResponse { this.#end(); }); response.once("close", () => { - // Its pending writes fail + // Its pending writes fail (and `flush()` rejects by itself) compressor.destroy(); }); return compressor; } + + /** + * Flushes the compressor: the callback runs once the response has flushed + * the compressed bytes so far (the flushed ones may still wait in the + * compressor, paused by backpressure) + */ + #flushCompressor(compressor: ICompressor, callback: ICallback): void { + compressor.flush(this.#kind, () => { + this.#waiters.push({ + target: this.#produced + compressor.readableLength, + callback, + }); + this.#release(); + }); + } + + /** + * Runs the flushes whose compressed bytes the response has flushed + */ + #release(): void { + const ready = this.#waiters.filter(({ target }) => target <= this.#written); + this.#waiters = this.#waiters.filter( + ({ target }) => target > this.#written, + ); + for (const { callback } of ready) { + callback(); + } + } } diff --git a/packages/compression/test/flush.test.ts b/packages/compression/test/flush.test.ts new file mode 100644 index 00000000..f0fc3fdb --- /dev/null +++ b/packages/compression/test/flush.test.ts @@ -0,0 +1,399 @@ +import { + Compression, + type ICompressEncodings, + isCompressible, +} from "../index.js"; +import type { ContentType, Context } from "binden"; +import { + type IServed, + decode, + fetchRaw, + sameBytes, + serve, + sleep, + within, +} from "./helpers.js"; +import { type IncomingMessage, request } from "node:http"; +import { afterEach, suite, test } from "node:test"; +import { deepStrictEqual, ok, rejects } from "node:assert"; +import type { Transform } from "node:stream"; +import { once } from "node:events"; +import { randomBytes } from "node:crypto"; +import zlib from "node:zlib"; + +const decompressors = new Map Transform>([ + ["br", (): Transform => zlib.createBrotliDecompress()], + ["deflate", (): Transform => zlib.createInflate()], + ["gzip", (): Transform => zlib.createGunzip()], + ["zstd", (): Transform => zlib.createZstdDecompress()], +]); + +/** A streaming decompressor of a coding */ +function decompressor(coding: string): Transform { + return (decompressors.get(coding) as () => Transform)(); +} + +/** The text that a stream has emitted so far, read as it comes */ +interface IReader { + readonly text: string; + /** Resolves once the text has `expected` (within 5 s) */ + until(expected: string): Promise; +} + +/** Reads a stream as it comes */ +function reader(stream: NodeJS.ReadableStream): IReader { + let received = ""; + stream.on("data", (chunk: Buffer) => { + received += chunk.toString(); + }); + return { + get text(): string { + return received; + }, + until: async (expected: string): Promise => { + const deadline = Date.now() + 5000; + while (!received.includes(expected)) { + if (Date.now() > deadline) { + throw new Error(`Not received within 5 s: ${received}`); + } + await sleep(5); + } + }, + }; +} + +/** Decompresses what a compressor has flushed (a stream that is not ended) */ +async function flushed(coding: string, data: Buffer): Promise { + const stream = decompressor(coding); + const read = reader(stream); + stream.write(data); + await sleep(20); + stream.destroy(); + return read.text; +} + +/** Sends a request, and resolves with its response (not read) */ +async function respond( + url: string, + headers: Record, + method = "GET", +): Promise { + const sent = request(url, { + agent: false, + headers, + method, + signal: AbortSignal.timeout(5000), + }).end(); + const [response] = (await within(once(sent, "response"))) as [ + IncomingMessage, + ]; + return response; +} + +const sse = "text/event-stream"; + +/** Compresses event streams as well */ +const filter = (_context: Context, type: ContentType | null): boolean => + type?.type === sse || isCompressible(type); + +suite("flush()", () => { + let served: IServed | null = null; + + afterEach(async () => { + const current = served; + served = null; + await current?.close(); + }); + + /** + * Server-sent events, each one flushed: the client reads the first one + * before the second one is sent + */ + const events = async (coding: ICompressEncodings): Promise => { + const { promise: received, resolve } = Promise.withResolvers(); + const written: Buffer[] = []; + const after: { written: Buffer; acknowledged: boolean }[] = []; + let calls = 0; + let acknowledged = 0; + served = await serve( + {}, + // The compressed chunks, as the compression writes them + (context: Context) => { + const { response } = context; + const write = response.write.bind(response); + response.write = ((chunk: Buffer | string, callback: () => void) => { + calls += 1; + written.push(Buffer.from(chunk)); + return write(chunk, () => { + acknowledged += 1; + callback(); + }); + }) as typeof response.write; + }, + new Compression({ encodings: [coding], filter }), + async (context: Context) => { + const { response } = context; + context.done = true; + response.setHeader("Content-Type", sse); + response.write("data: 1\n\n"); + await response.flush(); + after.push({ + written: Buffer.concat(written), + acknowledged: calls > 0 && acknowledged === calls, + }); + await received; + response.write("data: 2\n\n"); + await response.flush(); + response.end(); + }, + ); + + const response = await respond(served.url, { "Accept-Encoding": coding }); + deepStrictEqual(response.headers["content-type"], sse); + deepStrictEqual(response.headers["content-encoding"], coding); + const ended = once(response, "end"); + const read = reader(response.pipe(decompressor(coding))); + await read.until("data: 1\n\n"); + resolve(null); + await read.until("data: 2\n\n"); + await within(ended); + + deepStrictEqual(read.text, "data: 1\n\ndata: 2\n\n"); + // Once `flush()` has resolved, the compressed event has been written + deepStrictEqual(after.length, 1); + const [first] = after; + ok(first.acknowledged); + deepStrictEqual(await flushed(coding, first.written), "data: 1\n\n"); + }; + + test("server-sent events, flushed after each event (br)", async () => { + await events("br"); + }); + + test("server-sent events, flushed after each event (deflate)", async () => { + await events("deflate"); + }); + + test("server-sent events, flushed after each event (gzip)", async () => { + await events("gzip"); + }); + + test("server-sent events, flushed after each event (zstd)", async () => { + await events("zstd"); + }); + + test("event streams are not compressed by default", async () => { + served = await serve({}, new Compression(), async (context: Context) => { + context.setHeader("Content-Type", sse); + context.response.write("data: 1\n\n"); + await context.response.flush(); + context.response.end(); + context.done = true; + }); + const response = await fetchRaw(served.url, { + headers: { "Accept-Encoding": "gzip" }, + }); + + ok(!("content-encoding" in response.headers)); + ok(!("vary" in response.headers)); + deepStrictEqual(response.body, "data: 1\n\n"); + }); + + test("an uncompressed response is flushed as without compression", async () => { + const { promise: received, resolve } = Promise.withResolvers(); + served = await serve( + {}, + new Compression({ filter }), + async (context: Context) => { + context.done = true; + context.setHeader("Content-Type", sse); + context.response.write("data: 1\n\n"); + await context.response.flush(); + await received; + context.response.end(); + }, + ); + const response = await respond(served.url, { + "Accept-Encoding": "identity", + }); + ok(!("content-encoding" in response.headers)); + const ended = once(response, "end"); + const read = reader(response); + await read.until("data: 1\n\n"); + resolve(null); + await within(ended); + }); + + test("`flush()` before a write commits the headers", async () => { + const { promise: received, resolve } = Promise.withResolvers(); + served = await serve( + {}, + new Compression({ filter }), + async (context: Context) => { + context.done = true; + context.setHeader("Content-Type", sse); + await context.response.flush(); + await received; + context.response.end("data: 1\n\n"); + }, + ); + + // The headers come before the body + const response = await respond(served.url, { "Accept-Encoding": "gzip" }); + deepStrictEqual(response.headers["content-encoding"], "gzip"); + resolve(null); + const read = reader(response.pipe(zlib.createGunzip())); + await read.until("data: 1\n\n"); + }); + + test("`flush()` of a response without a compressor, or ended", async () => { + const results: unknown[] = []; + served = await serve( + {}, + new Compression({ format: "gzip", filter }), + async (context: Context) => { + const { response } = context; + context.done = true; + response.setHeader("Content-Type", sse); + const { pathname } = context.url; + if (pathname === "/ended") { + response.write("data: 1\n\n"); + response.end(); + } + await response.flush().then( + () => results.push(`${context.request.method} ${pathname}`), + (error: unknown) => results.push(error), + ); + response.end(); + }, + ); + + const head = await fetchRaw(served.url, { method: "HEAD" }); + deepStrictEqual(head.headers["content-encoding"], "gzip"); + const ended = await fetchRaw(served.url, { path: "/ended" }); + deepStrictEqual(ended.body, "data: 1\n\n"); + deepStrictEqual(results, ["HEAD /", "GET /ended"]); + }); + + test("`flush()` of a replaced response resolves", async () => { + const { promise: flushed406, resolve } = Promise.withResolvers(); + served = await serve( + {}, + new Compression({ filter }), + async (context: Context) => { + context.done = true; + context.setHeader("Content-Type", sse); + context.response.write("data: 1\n\n"); + resolve(await context.response.flush().then(() => "resolved")); + }, + ); + const response = await fetchRaw(served.url, { + headers: { "Accept-Encoding": "identity;q=0" }, + }); + + deepStrictEqual(response.status, 406); + deepStrictEqual(await within(flushed406), "resolved"); + }); + + test("`flush()` waits for the compressed bytes that the response holds back", async () => { + const data = randomBytes(1024 * 1024); + const written: Buffer[] = []; + const { promise: outcome, resolve } = Promise.withResolvers(); + served = await serve( + {}, + // A response that is always full: each compressed chunk waits for the + // one before, the others wait in the compressor. Its written chunks are + // those whose callback has run. + (context: Context) => { + const { response } = context; + const write = response.write.bind(response); + response.write = ((chunk: Buffer | string, callback: () => void) => { + write(chunk, () => { + setTimeout(() => { + written.push(Buffer.from(chunk)); + callback(); + response.emit("drain"); + }, 1); + }); + return false; + }) as typeof response.write; + }, + new Compression({ format: "gzip", filter: (): boolean => true }), + async (context: Context) => { + context.done = true; + context.response.write(data); + await context.response.flush(); + resolve(Buffer.concat(written)); + context.response.end(); + }, + ); + + const response = await fetchRaw(served.url); + const compressed = await within(outcome); + + // Everything written before `flush()`, as the client can decompress it + const { Z_SYNC_FLUSH } = zlib.constants; + sameBytes(zlib.gunzipSync(compressed, { finishFlush: Z_SYNC_FLUSH }), data); + sameBytes(decode("gzip", response.raw), data); + }); + + test( + "`flush()` rejects when the response is closed before it is flushed", + { timeout: 15_000 }, + async () => { + const { promise: pending, resolve: started } = + Promise.withResolvers(); + const { promise: outcome, resolve } = Promise.withResolvers(); + served = await serve( + {}, + new Compression({ format: "gzip", filter: (): boolean => true }), + (context: Context) => { + context.done = true; + // More than the connection takes, from a client that does not read + context.response.write(randomBytes(8 * 1024 * 1024)); + context.response.flush().then( + () => { + resolve(null); + }, + (error: unknown) => { + resolve((error as { code?: unknown }).code); + }, + ); + started(null); + }, + ); + + const response = await respond(served.url, {}); + response.pause(); + await within(pending); + response.destroy(); + + deepStrictEqual(await within(outcome), "ERR_STREAM_PREMATURE_CLOSE"); + }, + ); + + test("a flush after the response has closed rejects", async () => { + const { promise: outcome, resolve } = Promise.withResolvers(); + served = await serve( + {}, + new Compression({ format: "gzip", filter }), + async (context: Context) => { + context.done = true; + context.setHeader("Content-Type", sse); + context.response.write("data: 1\n\n"); + await context.response.flush(); + await once(context.response, "close"); + await rejects(context.response.flush(), { + code: "ERR_STREAM_PREMATURE_CLOSE", + }); + resolve("rejected"); + }, + ); + const response = await respond(served.url, {}); + const read = reader(response.pipe(zlib.createGunzip())); + await read.until("data: 1\n\n"); + response.destroy(); + + deepStrictEqual(await within(outcome), "rejected"); + }); +}); From 250c4e7f2c0b2ac4a447bd5904073b73743f03d3 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 02:25:57 +0300 Subject: [PATCH 171/675] docs: rewrite the README of @binden/compression The README described the former compression: `br` without `Accept-Encoding`, no options but `format`, and a decision taken when the middleware runs. It now documents the options (`encodings`, `threshold`, `filter` and its default, the options of the compressors, the quality 4 of `br`), the decision at the commit of the headers and the responses that are sent as they are, the negotiation and its `406`, the weak `ETag` and the `304` of a compressed response, `skipCompression()`, `flush()` with server-sent events, and the support of zstd. Its examples type-check. Co-Authored-By: Claude Opus 5.5 --- packages/compression/README.md | 193 +++++++++++++++++++++++++++++++-- 1 file changed, 182 insertions(+), 11 deletions(-) diff --git a/packages/compression/README.md b/packages/compression/README.md index e0432692..9b625d39 100644 --- a/packages/compression/README.md +++ b/packages/compression/README.md @@ -1,6 +1,6 @@ # @binden/compression [![CI Status](https://github.com/binden-js/binden/actions/workflows/ci.yml/badge.svg)](https://github.com/binden-js/binden/actions/workflows/ci.yml) [![version](https://img.shields.io/npm/v/@binden/compression?style=plastic)](https://www.npmjs.com/package/@binden/compression) [![Coverage Status](https://coveralls.io/repos/github/binden-js/binden/badge.svg?branch=main)](https://coveralls.io/github/binden-js/binden?branch=main) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/binden-js/binden/blob/main/CODE_OF_CONDUCT.md) [![semantic-release](https://img.shields.io/badge/semantic--release-conventional--commits-e10079.svg?logo=semantic-release)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) ![node version](https://img.shields.io/node/v/@binden/compression) ![npm downloads](https://img.shields.io/npm/dt/@binden/compression) [![License](https://img.shields.io/npm/l/@binden/compression)](https://github.com/binden-js/binden/blob/main/packages/compression/LICENSE) -[Binden](https://github.com/binden-js/binden) compression middleware (supports `br`, `gzip` and `deflate`). +[Binden](https://github.com/binden-js/binden) compression middleware: `br`, `gzip`, `deflate` and (opt-in) `zstd`. ## Installation @@ -11,22 +11,193 @@ npm install @binden/compression ## Usage ```typescript -import Compression from "@binden/compression"; +import { Binden } from "binden"; +import compression, { Compression } from "@binden/compression"; -app.use(Compression); +// With the default options +const app = new Binden().use(compression); + +// Or with options of its own +const other = new Binden().use( + new Compression({ threshold: 2048, encodings: ["br", "gzip"] }), +); + +app.createServer().listen(8080); +other.createServer().listen(8081); +``` + +The compression replaces `writeHead()`, `write()`, `end()`, `flush()` and `send()` on the response of +each request, so that every writer (the following middlewares, `sendFile()`, the error handler, a +piped stream) goes through it. It is installed once per response: a second compression on the +same response does nothing. + +## Options + +| Option | Default | Description | +| --------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `format` | `"auto"` | `"auto"` negotiates the coding with `Accept-Encoding`; `"br"`, `"gzip"`, `"deflate"` or `"zstd"` always uses that coding | +| `encodings` | `["br", "gzip", "deflate"]` | The codings that `"auto"` offers, the preferred first (only with `"auto"`). `zstd` is offered only when listed | +| `threshold` | `1024` | A response whose `Content-Length` is smaller (in bytes) is not compressed; a response without a length is | +| `filter` | `isCompressible` | `(context, content_type) => boolean`: whether a response is compressed, by its `Content-Type` (`null` without a valid one) | +| `gzip`, `deflate` | `{}` | The [options](https://nodejs.org/api/zlib.html#class-options) of the compressors | +| `brotli` | `BROTLI_PARAM_QUALITY` `4` | The [options](https://nodejs.org/api/zlib.html#class-brotlioptions) of the `br` compressor | +| `zstd` | `{}` | The [options](https://nodejs.org/api/zlib.html#class-zstdoptions) of the `zstd` compressor | +| `disabled`, `ignore_errors` | `false` | As for every `Middleware` | + +Invalid options throw a `TypeError` (or a `RangeError`) in the constructor: also invalid options of a +compressor (one is created to check them), and `zstd` (by `format` or `encodings`) on a version of +Node.js whose `node:zlib` does not have it. + +The quality of `br` is 4 by default, rather than the 11 of `node:zlib`: 11 is meant for compressing +ahead of time, and is far too slow for responses compressed as they are sent. Set it with +`brotli: { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } }` (e.g. for a few large responses +that are cached). + +### `filter` + +The default filter, `isCompressible()`, compresses text (`text/*`, but `text/event-stream`), JSON and +XML (also as `+json` and `+xml`, such as `image/svg+xml`), JavaScript, WebAssembly, and uncompressed +fonts and images (`font/ttf`, `font/otf`, `image/bmp`, …). A response without a valid `Content-Type` is +not compressed. + +`filter` is called synchronously, when the headers of a response are committed. It must return a +boolean: anything else (a promise as well) is a configuration error, which is logged (`error`) and +replaces the response with an empty `500 Internal Server Error`, as does a filter that throws. + +```typescript +import { Binden, type ContentType, type Context } from "binden"; +import { Compression, isCompressible } from "@binden/compression"; + +// Also compresses `application/x-ndjson`, but never the responses of `/download` +const compression = new Compression({ + filter: (context: Context, type: ContentType | null): boolean => + !context.url.pathname.startsWith("/download") && + (type?.type === "application/x-ndjson" || isCompressible(type)), +}); + +new Binden().use(compression).createServer().listen(8080); +``` + +## When a response is compressed + +The decision is taken for each response when its headers are committed (by `writeHead()`, +`flushHeaders()`, `flush()`, or the first `write()` or `end()`), when its status and its +representation are known: whatever the following middlewares set before is taken into account. A +response to `HEAD` gets exactly the headers of the response to `GET`. + +A response is sent as it is when: + +- it has no body: a `1xx`, `204`, `205` or `304` status, or an empty body (`Content-Length: 0`); +- it is a range (`206`, or any `Content-Range`): ranges refer to the representation as it is; +- it is encoded already (`Content-Encoding` other than `identity`; an `identity` is removed); +- it has `Cache-Control: no-transform`; +- `skipCompression()` has been called for it; +- the `filter` refuses it, or its `Content-Length` is below the `threshold`; +- the compression ran once its headers had been sent (the response is not changed at all). + +A fixed `format` does not bypass any of these, and such a response is sent whatever the request +accepts (also with `identity;q=0`). + +```typescript +import { Binden, type Context } from "binden"; +import compression, { skipCompression } from "@binden/compression"; + +// A response that is never compressed: before its headers are committed +const handler = (context: Context): Promise => { + skipCompression(context.response); + return context.text("as it is"); +}; + +new Binden().use(compression).use("/raw", handler).createServer().listen(8080); ``` ### Content negotiation -With the default `format: "auto"`, the content coding is chosen from the `Accept-Encoding` request header ([RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.3)): +With `format: "auto"`, the coding is chosen with the `Accept-Encoding` of the request +([RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.3)), and `Vary: Accept-Encoding` +is added to the response: + +- without `Accept-Encoding`, or with an empty one, the response is not compressed; +- otherwise, the coding of `encodings` with the greatest weight (its own `q`, or the one of `*`; `x-gzip` + is `gzip`) wins, the earlier one of `encodings` on a tie, unless `identity` weighs more. Browsers + (`gzip, deflate, br, zstd`) get `br`; +- when `identity` is refused (`identity;q=0`, or `*;q=0` without `identity`) and no coding of + `encodings` is acceptable, the response is replaced with `406 Not Acceptable`: an empty body + (`Content-Length: 0`) without the headers of the representation. What the application writes + afterwards is discarded (the callbacks of its writes still run). + +A fixed `format` compresses every response that may be compressed, whatever the request accepts (and +without `Vary`): use it when every client is known to support it (e.g. behind a proxy that +negotiates). + +### A compressed response + +- `Content-Encoding` names the coding; `Content-Length` and `Accept-Ranges` are removed (ranges of + the compressed body are not served); +- a strong `ETag` becomes weak (`W/"…"`, [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#section-8.8.3.3)): + it was set for the bytes of the uncompressed body. It still validates caches (`If-None-Match` + compares weakly), but no longer ranges (`If-Range`) nor `If-Match`; +- a `304 Not Modified` (e.g. of `sendFile()`, or of the dynamic `ETag` of binden) gets the same + decision as the `200` that it stands for: the same `ETag` and `Vary`, without `Content-Length` and + `Accept-Ranges` when the `200` is compressed, but without `Content-Encoding` (a cache that updates + its stored response with it could mislabel an uncompressed one). The `threshold` applies to it by + the length of the body given to `send()`; without one (e.g. `sendFile()`), as to a body without a + length; +- errors and backpressure are kept: `write()` returns `false` while the compressor is full, the + response emits `drain` once it is not, the callbacks of `write()` get the errors, the callback of + `end()` runs once the response has finished, and `writableEnded` is set by `end()`. A client that + goes away closes the compressor (its pending writes fail). + +## `flush()` and server-sent events + +`response.flush()` of a compressed response flushes the compressor (`Z_SYNC_FLUSH`, +`BROTLI_OPERATION_FLUSH` or `ZSTD_e_flush`): what has been written so far can be decompressed by the +client. Its promise resolves once the response has written the flushed bytes, and rejects if the +response is closed (or fails) before. Without a compressor, it is the `flush()` of binden. -- without the header, `br` is used; -- otherwise, the coding (`br`, `gzip`, `deflate`) with the highest effective quality wins: its own `q` value, or the one of `*`, while `q=0` refuses a coding. Ties are resolved in the order `br`, `gzip`, `deflate`; -- the response is not compressed when `identity` is explicitly preferred, or when no supported coding is acceptable (the header is then disregarded instead of replying with `406 Not Acceptable`); -- `Vary: Accept-Encoding` is added to the response. +Server-sent events (`text/event-stream`) are not compressed by default: an event would wait in the +compressor. A filter may compress them, with a `flush()` after each complete event: -A fixed `format` (`br`, `gzip` or `deflate`) compresses every response regardless of the request. +```typescript +import { Binden, type ContentType, type Context } from "binden"; +import { Compression, isCompressible } from "@binden/compression"; + +const sse = new Compression({ + filter: (_context: Context, type: ContentType | null): boolean => + type?.type === "text/event-stream" || isCompressible(type), +}); + +const events = async (context: Context): Promise => { + const { response } = context; + context.done = true; + response.setHeader("Content-Type", "text/event-stream"); + response.setHeader("Cache-Control", "no-cache"); + for (let id = 1; id <= 3 && !context.signal.aborted; id += 1) { + response.write(`id: ${id}\ndata: ${new Date().toISOString()}\n\n`); + // The event reaches the client now + await response.flush(); + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + } + response.end(); +}; + +new Binden().use("/events", sse, events).createServer().listen(8080); +``` -Partial responses (`206 Partial Content`, or any response with a `Content-Range` header, such as `416 Range Not Satisfiable`) are never compressed, because their ranges refer to the uncompressed body. Neither are responses without a body (1xx, `204`, `205`, `304`) or whose headers have already been sent. A response to `HEAD` gets the headers of the compressed response, without a body. +## zstd -A strong `ETag` of a compressed response becomes weak (`W/"…"`): it was set for the uncompressed body, whose bytes differ. It still validates caches (`If-None-Match` compares weakly), but no longer ranges and `If-Match`. +`zstd` is offered only when it is listed in `encodings` (or set as the `format`): it needs a version of +Node.js whose `node:zlib` has it (22.15, 23.8 and later; it is tested from 24.14.1), and not every +client supports it (e.g. Safari does not). Without it in `node:zlib`, the constructor throws a +`TypeError`. + +```typescript +import { Binden } from "binden"; +import { Compression } from "@binden/compression"; + +const compression = new Compression({ encodings: ["zstd", "br", "gzip"] }); + +new Binden().use(compression).createServer().listen(8080); +``` From d1f64c4c83005b3d835f802246e9fe12d54b948f Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 02:34:28 +0300 Subject: [PATCH 172/675] test: serve the dynamic ETag through the option of binden in @binden/compression The tests of the conditional requests replaced the response class of the application with a subclass that enabled the dynamic `ETag`: `callback()` now refuses the requests whose classes are not those of the application (and closes their connection). They use the `etag` option of `Binden` instead. Co-Authored-By: Claude Opus 5.5 --- packages/compression/test/helpers.ts | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/packages/compression/test/helpers.ts b/packages/compression/test/helpers.ts index c1afdda5..c04befdf 100644 --- a/packages/compression/test/helpers.ts +++ b/packages/compression/test/helpers.ts @@ -1,11 +1,4 @@ -import { - Binden, - BindenResponse, - type IBindenOptions, - type IMiddleware, - type IResponseOptions, - validateResponseOptions, -} from "binden"; +import { Binden, type IBindenOptions, type IMiddleware } from "binden"; import { type IncomingHttpHeaders, type IncomingMessage, @@ -81,14 +74,6 @@ export function decode( return decoder(body); } -/** A response with the dynamic `ETag` of binden */ -export class EtagResponse extends BindenResponse { - // eslint-disable-next-line class-methods-use-this -- the options of a subclass - protected override get options(): Readonly { - return validateResponseOptions({ etag: true }); - } -} - /** A logger whose records are kept */ export interface ICaptured { readonly logger: Logger; @@ -140,7 +125,7 @@ export interface IServed { export interface IServeOptions { /** The options of the application */ readonly app?: IBindenOptions | undefined; - /** Responses with the dynamic `ETag` */ + /** Responses with the dynamic `ETag` (the `etag` option of the application) */ readonly etag?: boolean | undefined; /** `rejectNonStandardBodyWrites` of the server */ readonly strict?: boolean | undefined; @@ -151,12 +136,9 @@ export async function serve( { app: options = {}, etag = false, strict = false }: IServeOptions, ...middlewares: IMiddleware[] ): Promise { - const app = new Binden(options).use(...middlewares); + const app = new Binden({ ...options, etag }).use(...middlewares); const server = createServer({ ...app.serverOptions(), - ...(etag - ? { ServerResponse: EtagResponse as unknown as typeof BindenResponse } - : {}), rejectNonStandardBodyWrites: strict, }); server.on("request", app.callback(server)); From 3dcfe868217ca87ca60f1e35ce7078e8ed4f74b4 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 02:34:33 +0300 Subject: [PATCH 173/675] build: list the new modules of the middlewares in the packed files The body, multipart and compression packages are split into modules under `src/`: the snapshot of their published files lists them, so that `npm run pack:check` and the release check them. Co-Authored-By: Claude Opus 5.5 --- scripts/pack-files.json | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/scripts/pack-files.json b/scripts/pack-files.json index e8596cb8..83efb4a4 100644 --- a/scripts/pack-files.json +++ b/scripts/pack-files.json @@ -93,6 +93,14 @@ "README.md", "dist/index.d.ts", "dist/index.js", + "dist/src/codings.d.ts", + "dist/src/codings.js", + "dist/src/filter.d.ts", + "dist/src/filter.js", + "dist/src/negotiate.d.ts", + "dist/src/negotiate.js", + "dist/src/response.d.ts", + "dist/src/response.js", "package.json" ], "@binden/multipart": [ @@ -100,6 +108,20 @@ "README.md", "dist/index.d.ts", "dist/index.js", + "dist/src/disk.d.ts", + "dist/src/disk.js", + "dist/src/file.d.ts", + "dist/src/file.js", + "dist/src/filename.d.ts", + "dist/src/filename.js", + "dist/src/info.d.ts", + "dist/src/info.js", + "dist/src/multipart.d.ts", + "dist/src/multipart.js", + "dist/src/options.d.ts", + "dist/src/options.js", + "dist/src/race.d.ts", + "dist/src/race.js", "package.json" ] } From 3ca7c8646129e324c3d3dfa6bbe0bd42c9a68efc Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 02:58:00 +0300 Subject: [PATCH 174/675] docs: add the guides to migrate to binden 4 and to run it in production binden 4.0.0 changes the five packages at once, and most of its breaking changes are statuses, headers and logs that only show at runtime: MIGRATION-4.md lists every BREAKING CHANGE footer since v3.0.0 by package, with what to change and why, from binden 2.0.4, @binden/logger 2.0.3, @binden/body 2.0.1, @binden/compression 3.0.0 and @binden/multipart 2.0.3. The defaults of binden suit development (no deadline, no body limit, no trusted proxy, any host), so PRODUCTION.md sets up a server step by step: the timeouts of Node.js and of binden, shutdown() on SIGTERM, proxies trusted by address with their header family, allowed hosts and request ids, the limits of the parsers with the disk storage, logging, sendFile() with a root, compression, server-sent events, security headers (CSP, HSTS) and HTTP/2 at the proxy, with a checklist. Every TypeScript example type checks against the packages, strictly, like the README examples. TypeDoc publishes both guides with the API documentation (projectDocuments), and highlights their diff and nginx blocks. Co-Authored-By: Claude Opus 5.5 --- MIGRATION-4.md | 521 ++++++++++++++++++++++++++++++++++++++++++++++ PRODUCTION.md | 551 +++++++++++++++++++++++++++++++++++++++++++++++++ typedoc.json | 3 + 3 files changed, 1075 insertions(+) create mode 100644 MIGRATION-4.md create mode 100644 PRODUCTION.md diff --git a/MIGRATION-4.md b/MIGRATION-4.md new file mode 100644 index 00000000..92e86635 --- /dev/null +++ b/MIGRATION-4.md @@ -0,0 +1,521 @@ +# Migrating to binden 4 + +binden 4.0.0 is the first release of the packages of this repository together: `binden`, +`@binden/logger`, `@binden/body`, `@binden/compression` and `@binden/multipart` are now released +at the same time, always with the same version. This guide lists what changes since their last +separate releases, and how to update an application: + +| Package | Last release before 4.0.0 | Its Node.js | Its license | +| --------------------- | ------------------------- | ----------- | ------------- | +| `binden` | 2.0.4 | `>=20.11.0` | AGPL-3.0-only | +| `@binden/logger` | 2.0.3 | `>=20.11.0` | AGPL-3.0-only | +| `@binden/body` | 2.0.1 | `>=20.11.0` | AGPL-3.0-only | +| `@binden/compression` | 3.0.0 | `>=24.14.1` | AGPL-3.0-only | +| `@binden/multipart` | 2.0.3 | `>=22.17.0` | AGPL-3.0-only | + +Most changes make a request that used to be accepted, or answered with a misleading status, fail +safely. Each section starts with what fails at once (a type error, or a `TypeError` when the +application starts), then what changes silently (statuses, headers, logs). The +[production guide](PRODUCTION.md) shows how to configure the new options (deadlines, shutdown, +proxies, limits). + +- [Upgrading](#upgrading) +- [All packages](#all-packages) +- [binden](#binden) +- [@binden/logger](#bindenlogger) +- [@binden/body](#bindenbody) +- [@binden/multipart](#bindenmultipart) +- [@binden/compression](#bindencompression) +- [New features](#new-features) + +## Upgrading + +1. Run Node.js 24.14.1 or later. +2. Install version 4 of every package that the application uses, together: + + ```bash + npm install binden@^4.0.0 @binden/logger@^4.0.0 @binden/body@^4.0.0 @binden/compression@^4.0.0 @binden/multipart@^4.0.0 + ``` + + `@binden/body`, `@binden/compression` and `@binden/multipart` have a peer dependency on + `binden` of their own version (`^4.0.0`): npm refuses to install them next to `binden` 2. + +3. Replace the deep imports (see [Entry points](#entry-points)), then compile: most of the + changes of the API are type errors. +4. Read the sections of the packages that the application uses, and check its tests against the + new statuses. + +## All packages + +### Versions + +Every release has one version for all the packages: a fix of one package is released as a new +version of all of them, and the middlewares depend on the `binden` of the same version. The notes +of every release are the [GitHub Releases](https://github.com/binden-js/binden/releases); the +`CHANGELOG.md` of each package keeps the history before 4.0.0. + +### Node.js 24.14.1 + +Every package requires Node.js `>=24.14.1` (see the table above for the earlier requirements). + +### Entry points + +The packages are ES modules (as before) with an `exports` map: only the entry point of a package +(`binden`, `@binden/logger`, …) can be imported. A deep import of one of their files fails with +`ERR_PACKAGE_PATH_NOT_EXPORTED` (and does not compile): import everything from the entry point, +which exports all of the public API. + +```diff +-import Cookie from "binden/dist/src/headers/cookie.js"; ++import { Cookie } from "binden"; +``` + +With TypeScript, use the `"nodenext"` (or `"bundler"`) module resolution, which reads the +`exports` maps. The types of `pino` (used by `@binden/logger`) need `skipLibCheck` with +`@types/node` 26 (see the [known issues](https://github.com/binden-js/binden#known-issues)). + +### MIT license + +All the packages are published under the [MIT license](LICENSE) (they were under the +AGPL-3.0-only). + +## binden + +### Options are checked + +`new Binden()`, `new Router()` and the constructor of every `Middleware` validate their options: +a value of the wrong type throws a `TypeError` (e.g. `new Router({ guarded: 1 })`, or a `logger` +without the methods of `ILogger`), and a value out of its range a `RangeError`. They used to be +coerced or ignored. + +`IBindenOptions` is a type (with a `State` parameter), no longer an interface: an interface cannot +extend it. Extend `IBindenBaseOptions` (every option but `state`) instead, or intersect the type +(`IBindenOptions & { port: number }`): + +```typescript +import { Binden, type IBindenBaseOptions } from "binden"; + +// `interface IServerOptions extends IBindenOptions` no longer compiles +interface IServerOptions extends IBindenBaseOptions { + port: number; +} + +function start({ port, ...options }: IServerOptions): void { + new Binden(options).createServer().listen(port); +} + +start({ port: 8080, timeout: 30_000 }); +``` + +### Status codes + +- `response.status()` takes an integer from 100 to 599 (a `TypeError` otherwise), and + `new BindenError()` an integer from 400 to 599 (a `TypeError` for a string such as `"400"`, a + `RangeError` out of the range). Any integer in the range is accepted, such as `499`: only the + codes known to Node.js were, also as strings. +- The default error reply uses the `status` of a thrown value only when it is an integer from 400 + to 599: any other value (a string such as `"404"`, or `302`) gets `500 Internal Server Error`. + It used to accept any status known to Node.js, also as a string. + +```diff +-throw Object.assign(new Error("Not found"), { status: "404" }); ++throw new BindenError(404); +``` + +### The error handler + +The `error_handler` option was called without being awaited: a handler that rejected crashed the +process with an unhandled rejection, and one that did not reply left the request pending. Now: + +- its result is awaited, for at most `error_handler_timeout` milliseconds (`5000` by default); +- a handler that has not started a reply once it settles gets the default reply instead. A reply + that it sends later (e.g. without awaiting it) is too late: make the handler `async` and await + its reply; +- a handler that throws, rejects or takes longer gets an empty `500 Internal Server Error` + (without the headers of the body it may have set), or its connection is closed when its reply + has started. + +```typescript +import { Binden, BindenError, type Context } from "binden"; + +const app = new Binden({ + error_handler_timeout: 2000, + error_handler: async (context: Context, error: unknown): Promise => { + const status = error instanceof BindenError ? error.status : 500; + // Awaited: the reply must be complete once the handler settles + await context.status(status).json({ error: status }); + }, +}); +``` + +The default error reply is sent with the cookies of the response and the `headers` of a +`BindenError`, without the headers of the body that it replaces (`Content-Length`, +`Content-Encoding`, `ETag`, …). When a reply has started, the error cuts it by closing the +connection, so that the client knows that it is incomplete (it was ended as if it were complete), +and a reply that has ended is left as it is (its request was destroyed). + +### Routing + +- `Router.on()` and `Router.off()` take any method token in any case (`router.on("propfind")` is + `PROPFIND`), instead of the methods of Node.js in uppercase only. `CONNECT` throws a `TypeError`: + Node.js never passes its requests to the request listener. +- A guarded router that does not allow the method of a request passes it on: the next routers of + the path are tried, and `405 Method Not Allowed` is replied only when none handles it. Its + `Allow` lists the methods of every guarded router that has refused the request, with `HEAD` + (for `GET`, with `auto_head`) and `OPTIONS`. +- `OPTIONS` to guarded routers without `OPTIONS` middlewares gets `204 No Content` with the same + `Allow` (it was a `405`). +- With `auto_head`, the `HEAD` middlewares of a router run when it has some; its `GET` middlewares + handle `HEAD` only otherwise (they used to handle it in any case). +- `Binden` is a `Stack`: `app.stack` lists the layers of `use()`, `route()` and `mount()`, whose + path is a string, a RegExp, `null`, `{ route }` or `{ mount }`. A string path of `use()` still + matches exactly; the new `route()` and `mount()` take [patterns with parameters](https://github.com/binden-js/binden/tree/main/packages/binden#stack-and-routing). + +### Replies + +`json()` sends any JSON value (not only an object or an array). A value without a JSON text +(`undefined`, a function, a symbol), a `bigint` or a cyclic value rejects with a `TypeError` +before anything is sent: `undefined` used to send an empty body. + +```diff +-await context.json(undefined); ++await context.json(null); ++// or ++await context.status(204).send(); +``` + +`sendFile(path, stats)` takes options instead of the `Stats` (it reads the file and its metadata +from one file handle). It sends a weak `ETag` (`W/"-"`), a `Content-Type` from the +extension of the file and `X-Content-Type-Options: nosniff` by default. Never pass a path from the +request without the `root` option (see [Static files](PRODUCTION.md#static-files)). + +```typescript +// The headers of binden 2 (without an ETag and a Content-Type) +await context.sendFile("", { etag: false, content_type: false }); +``` + +- `text()` sends `text/plain; charset=utf-8` (it was `plain/text`, and `ct_text` is + `"text/plain"`), and `html()` sends `text/html; charset=utf-8`. +- A response is framed by its final status: a body gets its exact `Content-Length` (a stale one is + replaced), `1xx`, `204` and `304` responses have none, and a response to `HEAD` (or with a `1xx`, + `204`, `205` or `304` status) sends no body. A response without a body no longer gets a default + `Content-Type`. +- The cookies of `response.cookies` are added when the headers are sent by any path (`writeHead()`, + `write()`, `end()`, a piped stream), not only by `send()`. +- A middleware that sets `context.done` without starting a reply gets an empty response with the + status and the headers set so far (the request used to stay pending). A stack that runs out once + a response has started lets it finish, instead of replying `404`. + +### Requests + +#### Proxies + +`request.protocol` and `request.secure` followed the `Forwarded` header of any client: a client +could make a plain HTTP request look secure. They now ignore `Forwarded` unless the `trust_proxy` +option trusts the proxies that send it, and behind trusted proxies `request.URL` takes the host +that they forward. A trusted element with an invalid `for`, `proto` or `host` gives the protocol of +the connection. An application behind a TLS-terminating proxy must trust it: + +```typescript +import { Binden } from "binden"; + +// One proxy that sends `Forwarded` +const app = new Binden({ trust_proxy: 1 }); +// One proxy that sends X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host (nginx, AWS ALB) +const behindNginx = new Binden({ + trust_proxy: { trust: 1, headers: "x-forwarded" }, +}); +``` + +Trusting the proxies by their addresses is safer: see +[Reverse proxies](PRODUCTION.md#reverse-proxies). + +#### Request-targets + +- A request-target with a fragment, a backslash, an invalid percent-encoding or a dot-segment + (`/a/../b`, also percent-encoded), a request with several `Host` field lines, and a request + without a valid host get `400 Bad Request` (an invalid `Host` used to be a `500`, and several + were reduced to the first one). +- The scheme of an absolute-form target no longer sets `request.secure` and the protocol of + `request.URL`. +- A request whose path has `%2F` or `%5C` gets `400 Bad Request`: a router and a proxy (or a file + server) would split such a path differently. `encoded_slashes: true` accepts them (route + parameters then contain the decoded `/` or `\`). + +#### Headers + +`request.content_encoding` (and `ContentEncoding.fromString()`) is `null` when a coding is unknown +or malformed (it was a list), and `ContentEncoding` is no longer a subclass of `AcceptEncoding`: + +```typescript +import { BindenError, type Context } from "binden"; + +const decodable = (context: Context): void => { + if (context.request.content_encoding === null) { + throw new BindenError(415); + } +}; +``` + +- The constructors of the headers throw a `TypeError` for what their parsers reject: `Range`, + `AcceptEncoding`, `ContentType`, `ContentEncoding` (an unknown coding), `Authorization` (an + unsupported scheme, or one not in its usual casing), `Forwarded` (control characters) and + `IfModifiedSince` (an Invalid Date, or a year out of 0 to 9999). +- `ContentType#toString()` includes every parameter, and `Forwarded#toString()` quotes the values + that are not tokens. +- `new Cookie()` throws a `TypeError` for a name, a value or an attribute that would change the + `Set-Cookie` header (e.g. a value with `;`), and `request.cookies` skips such cookies. + +#### Servers and subclasses + +`createServer()` and `createSecureServer()` register the server for `shutdown()`. For a server +created by other means, `serverOptions()` gives the request and response classes of the +application, and `callback(server)` its request listener: a request of other classes (e.g. +`BindenRequest` itself) is logged and its connection closed. + +A subclass of `BindenRequest` (for a server created with `http.createServer()`) sets the request +options by overriding the protected `options` getter with the result of +`validateRequestOptions()` (the `trust_proxy` getter of the pre-release builds is gone): + +```typescript +import { createServer } from "node:http"; +import { + BindenRequest, + type IRequestOptions, + validateRequestOptions, +} from "binden"; + +const options = validateRequestOptions({ + trust_proxy: { trust: 1, headers: "x-forwarded" }, +}); + +class Request extends BindenRequest { + protected override get options(): IRequestOptions { + return options; + } +} + +createServer({ IncomingMessage: Request }); +``` + +### Logging + +- The framework logs its failures under the `failure` key, as a snapshot that it builds itself, + instead of `{ context, error }`: the type of the error (`BindenError`, `BindenAbortError` or the + nearest built-in error class), a known `code` (a system error, a few stream and HTTP codes, or + `deadline` and `aborted`), its `status`, the `request_id` and the `location` (`middleware`, + `error_handler`, `send`, `cleanup` or `shutdown`). Update the queries and alerts that read the + `error` of these records. +- The message, the stack, the cause and any other `code` of an error may quote the input of a + request: they are logged only with the `log_error_details: true` option. +- The default serializers log an allowlist of details: a `request` is `{ method, path }` + (without the query and the headers), a `response` is `{ status }`, a `context` is + `{ id, request, response }`, a `body` is redacted, and an `error` is `{ type, code, status }` + (without the `expose` and the `json` of a `BindenError`). The default logger is created when + it is first used, still with the level of `BINDEN_LOG_LEVEL`. +- `context.log` is an `ILogger` (it was the `Logger` class of `@binden/logger`): its methods + return `unknown`, and its calls never throw. The `logger` option takes any `ILogger`. + +To log more details (e.g. outside of production), pass a logger: + +```typescript +import { Binden } from "binden"; +import { Logger } from "@binden/logger"; + +// The messages and stacks of the errors, in the records of the application +// (`error_details`) and of the framework (`log_error_details`) +const logger = new Logger({ level: "debug", error_details: true }); +const app = new Binden({ logger, log_error_details: true }); +``` + +## @binden/logger + +- The default serializers log an allowlist of details: an `error` is its type, a known `code` and + its `status` (its message and stack only with the new `error_details: true` option), a `request` + is `{ method, path }` and a `response` is `{ status }`. A value of another kind under these keys + is reduced to `{ type }`. They were the standard serializers of `pino`, which log the headers + of the requests and the messages of the errors. +- The `serializers` option replaces the default serializers of its own keys only: it used to + replace all of them. +- `logger` (an existing `pino` logger) and `destination` throw a `TypeError` together: `logger` + used to be ignored. +- The default logger (`import log from "@binden/logger"`) is created when it is first used: + importing the package has no side effect, and `BINDEN_LOG_LEVEL` is read then. +- `pino` 10 is used (it was 9). + +```typescript +import { Logger } from "@binden/logger"; + +// Before: the default `request` and `response` serializers were dropped +const logger = new Logger({ + level: "info", + serializers: { + user: (user: { id: string }): Record => ({ id: user.id }), + }, +}); +logger.info("Signed in", { user: { id: "42", email: "user@example.com" } }); +``` + +New: `ILogger` (what binden needs), `child(bindings, { level })`, `level`, `isLevelEnabled()`, +`flush()`, `Logger.fromPino()` and `createSerializers()` (see the +[README](https://github.com/binden-js/binden/tree/main/packages/logger)). + +## @binden/body + +The statuses follow one rule: a body that cannot be parsed or decoded is a `400`, a body over a +limit a `413`, a coding or a charset that the parser does not support a `415`, and a failure of the +application a `500`. + +| Request | 2.0.1 | 4.0.0 | +| --------------------------------------------------------- | ----------------------------- | ------------------------------------------------ | +| Invalid JSON, corrupt compressed data | `415` | `400` | +| `parse` returns `undefined` | `request.body` is `undefined` | `400` | +| `parse` throws (other than a `BindenError`) | `415` | `500` | +| A JSON value other than an object or an array (e.g. `1`) | parsed | `400` (`strict: false` parses it) | +| An empty JSON body | `415` | `request.body` is `undefined` | +| A JSON body of whitespace only | `415` | `400` | +| Bytes that are not valid in the charset | replaced (`U+FFFD`) | `400` | +| A text body in another charset than UTF-8 | decoded as UTF-8 | decoded with its charset (`415` for unknown one) | +| More than two content codings | decoded | `415` | +| A form with more than 1000 parameters | parsed | `413` (`parameter_limit`) | +| A `+json` type (e.g. `application/ld+json`) | skipped | parsed as JSON | +| A request destroyed before it is read, an aborted request | skipped, or a `500` | `400` (code `aborted`) | + +- The `IBodyContentType` type is removed: the kinds of bodies are `IBodyKind` (`"json"`, + `"text"`, `"form"` and `"raw"`). +- A custom `parse` gets the decoded text (not called for an empty body) and may be `async`. It + rejects a body with `undefined` (`400`) or a `BindenError` (its status): an error that it lets + through (e.g. the `SyntaxError` of `JSON.parse()`) is now a `500`. +- Nothing limits the size of a body by default (as before): set `limit` and `encoded_limit`. + +```typescript +import { BindenError } from "binden"; +import { BodyParser } from "@binden/body"; + +interface IPayment { + amount: number; +} + +const parse = (input: string): IPayment | undefined => { + let value: unknown = null; + try { + value = JSON.parse(input); + } catch { + // Invalid JSON: `400 Bad Request` (a SyntaxError thrown here would be a 500) + return undefined; + } + const { amount } = (value ?? {}) as { amount?: unknown }; + if (typeof amount !== "number") { + throw new BindenError(422, { expose: true, message: "Invalid amount" }); + } + return { amount }; +}; + +const payments = new BodyParser({ parse, limit: 64 * 1024 }); +// Any JSON value at the top level, as before +const values = new BodyParser({ strict: false, limit: 1024 * 1024 }); +``` + +## @binden/multipart + +- The default export takes the options of `Multipart` (it took the configuration of busboy): + + ```diff + -const formData = multipart({ limits: { fields: 2, fileSize: 1024 } }); + +const formData = multipart({ config: { limits: { fields: 2, fileSize: 1024 } } }); + ``` + +- The options are validated when the middleware is created: an unknown option (also in `config` + and `config.limits`) or an invalid value throws a `TypeError` or a `RangeError`. +- A body over a limit of `config.limits` gets `413 Content Too Large` (it was a `415`), a malformed + body or boundary `400 Bad Request` (it was a `415`), and a `multipart/form-data` request without + a boundary `400` (it was skipped). An aborted request rejects with a `400` of code `aborted`. +- The parameters of the parts (e.g. the names of the files) are decoded as UTF-8 (busboy decodes + them as Latin-1 by default): set `config.defParamCharset` to `"latin1"` to keep the former names. +- A file name is cleaned: its control characters are removed, it is cut to 255 bytes, and an empty + one is `"blob"`. +- The files are `MultipartFile`s: `File`s with a `truncated` flag. +- `@types/busboy` is a dependency (it was a peer dependency): remove it from the application unless + the application uses busboy itself. + +```typescript +import multipart, { MultipartFile } from "@binden/multipart"; +import { type Context, Router } from "binden"; + +const MiB = 1024 * 1024; +const formData = multipart({ + limit: 10 * MiB, + config: { + limits: { fileSize: 5 * MiB, files: 10, fields: 100, parts: 1000 }, + }, +}); + +const upload = async (context: Context): Promise => { + const avatar = (context.request.body as FormData).get("avatar"); + if (!(avatar instanceof MultipartFile)) { + return context.throw(400, { expose: true, message: "No avatar" }); + } + await context.json({ name: avatar.name, size: avatar.size }); +}; + +app.use("/avatar", new Router().post(formData, upload)); +``` + +## @binden/compression + +The coding is negotiated when the headers of a response are committed, once its status and its +headers are known: + +- A request without `Accept-Encoding` (or with an empty one) gets an uncompressed response: it got + `br` (`DefaultCompression` is no longer a fallback). `Vary: Accept-Encoding` is added to the + responses whose coding depends on it. +- A request that refuses `identity` (`identity;q=0`, or `*;q=0` without `identity`) and accepts + none of the codings gets `406 Not Acceptable`. +- A response smaller than `threshold` (`1024` bytes), or of a type that the `filter` refuses + (`isCompressible()` by default: e.g. images, `text/event-stream`, or no `Content-Type`), is not + compressed. Nor is a response without a body, a range (`206`, `Content-Range`), an encoded + response or one with `Cache-Control: no-transform`, even with a fixed `format`. +- A compressed response loses its `Content-Length` and `Accept-Ranges`, and its strong `ETag` + becomes weak. +- `br` compresses with the quality 4 (it was 11, the default of `node:zlib`, which is meant for + compression ahead of time). +- A second compression of the same response does nothing (it added a second coding). +- The `IComressFormats` type is renamed `ICompressFormats`. + +```diff +-import type { IComressFormats } from "@binden/compression"; ++import type { ICompressFormats } from "@binden/compression"; +``` + +```typescript +import zlib from "node:zlib"; +import { Compression, isCompressible } from "@binden/compression"; + +const compression = new Compression({ + // The quality of binden 2 (slow: for a few large responses that are cached) + brotli: { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } }, + // From 256 bytes, and NDJSON too + threshold: 256, + filter: (_context, type) => + type?.type === "application/x-ndjson" || isCompressible(type), +}); +``` + +Server-sent events were compressed and held in the compressor: they are not compressed by +default, and a `filter` that compresses them must be followed by a `response.flush()` after each +event (see [Server-sent events](PRODUCTION.md#server-sent-events)). + +## New features + +These need no change, and are off by default unless noted: + +- `binden`: request deadlines (`timeout`, `context.signal`, `context.setTimeout()`), cleanups + (`context.onCleanup()`), graceful `shutdown()`, a typed `state`, `route()` and `mount()` with + typed parameters, `request.ip`, `request.host`, `allowed_hosts`, request ids, content + negotiation (`request.accepts()`, …), an access log, a dynamic `ETag`, `redirect()`, + `attachment()`, `type()`, `vary()`, `clearCookie()`, `flush()`, and the options of `sendFile()` + (see the [README](https://github.com/binden-js/binden/tree/main/packages/binden)); +- `@binden/body`: `limit`, `encoded_limit`, `type`, `raw`, `verify` and `zstd`; +- `@binden/multipart`: `limit`, the disk and stream storages, `file_filter` and `multipartInfo()`; +- `@binden/compression`: `encodings`, `threshold`, `filter`, the options of the compressors, + `zstd`, `flush()` and `skipCompression()`. diff --git a/PRODUCTION.md b/PRODUCTION.md new file mode 100644 index 00000000..988cb1d9 --- /dev/null +++ b/PRODUCTION.md @@ -0,0 +1,551 @@ +# Running binden in production + +The defaults of binden suit development: a request has no deadline, a request body has no size +limit, no proxy is trusted, and any host is accepted. This guide sets up a server for production, +one concern at a time; the [checklist](#checklist) at the end sums it up. The +[examples](https://github.com/binden-js/binden/tree/main/examples) are runnable servers that follow +it. + +- [Timeouts](#timeouts) +- [Graceful shutdown](#graceful-shutdown) +- [Reverse proxies](#reverse-proxies) +- [Request bodies](#request-bodies) +- [Logging](#logging) +- [Static files](#static-files) +- [Compression](#compression) +- [Server-sent events](#server-sent-events) +- [Security headers](#security-headers) +- [Checklist](#checklist) + +## Timeouts + +Two layers bound the time that a request takes: + +| Setting | Default | Bounds | Suggestion | +| ----------------------------------------------- | ------- | --------------------------------------------------------- | ----------------------------------------------------- | +| `headersTimeout` (Node.js server) | 60 s | Receiving the headers of a request | 10–20 s | +| `requestTimeout` (Node.js server) | 300 s | Receiving a whole request, its body included (else `408`) | Longer than the slowest upload that you accept | +| `keepAliveTimeout` (Node.js server) | 5 s | Keeping an idle connection open | Longer than the idle timeout of the proxy (e.g. 65 s) | +| `timeout` (`Binden`) | none | Handling a request, from its arrival (else `503`) | 30 s, more for the routes that need it | +| `error_handler_timeout` (`Binden`) | 5 s | A custom `error_handler` (else an empty `500`) | The default | +| `timeout` of [`shutdown()`](#graceful-shutdown) | 10 s | Completing the requests in progress at shutdown | Shorter than the grace period of the platform | + +- `headersTimeout` must not be longer than `requestTimeout` (Node.js throws a `RangeError`). +- Behind a proxy that keeps its connections to the server open (nginx with `keepalive`, a load + balancer), `keepAliveTimeout` must be longer than the idle timeout of the proxy (60 s for nginx + and an AWS Application Load Balancer, by default). Otherwise the server may close a connection + while the proxy sends a request on it, and the client gets a `502 Bad Gateway`. +- The deadline of binden (`timeout`) starts when the request arrives, and covers every middleware, + the reading of the body included. Once it has passed, `context.signal` is aborted (its reason is + a `BindenAbortError` of reason `deadline`), the stack stops, and the client gets + `503 Service Unavailable` (or the connection is closed, when the reply has started). A route + restarts it with `context.setTimeout(ms)` (from now), or removes it with `context.setTimeout(0)` + (e.g. for [server-sent events](#server-sent-events)). +- Pass `context.signal` to the work of a request (e.g. `fetch()`, a database query): it stops when + the client goes away, the deadline passes or the server shuts down. + +```typescript +import { Binden, type Context, Router } from "binden"; +import { Multipart } from "@binden/multipart"; + +const MiB = 1024 * 1024; +const app = new Binden({ timeout: 30_000 }); + +// An upload has 2 minutes from the start of its route, its body included +const longer = (context: Context): void => { + context.setTimeout(120_000); +}; +const uploads = new Multipart({ + storage: {}, + limit: 10 * MiB, + config: { + limits: { fileSize: 5 * MiB, files: 10, fields: 100, parts: 1000 }, + }, +}); +app.use( + "/uploads", + new Router().post(longer, uploads, (context) => context.status(201).send()), +); + +app.route("/users/:id", async (context) => { + // Stops once the client has gone away or the deadline has passed + const upstream = await fetch( + `https://users.example.com/${encodeURIComponent(context.params.id)}`, + { signal: context.signal }, + ); + await context.json(await upstream.json()); +}); + +app + .createServer({ + headersTimeout: 20_000, + requestTimeout: 120_000, + // Longer than the idle timeout of the proxy (60 s) + keepAliveTimeout: 65_000, + }) + .listen(8080); +``` + +## Graceful shutdown + +Platforms stop a process with `SIGTERM`, then kill it with `SIGKILL` once their grace period has +passed (30 s on Kubernetes by default, 10 s for `docker stop`). `shutdown(server)` closes a server +created by `createServer()` or `createSecureServer()` (or registered by `callback()`): + +1. at once, the server stops accepting connections, the idle connections are closed (and every + other one as soon as it is idle), and a request that still arrives on an open connection gets + `503 Service Unavailable` with `Connection: close`; +2. the requests in progress have `timeout` milliseconds (10 s by default) to complete; +3. then their `context.signal` is aborted (`shutdown`) and every connection is closed; +4. the promise resolves once the server has closed and the cleanups of the requests + (`context.onCleanup()`) have run, or rejects when they take longer than `cleanup_timeout` + milliseconds (1 s by default). + +```typescript +import { Binden, shutdown } from "binden"; +import { Logger } from "@binden/logger"; + +const logger = new Logger({ level: "info" }); +const app = new Binden({ logger, timeout: 30_000 }).use(middleware); +const server = app.createServer({ keepAliveTimeout: 65_000 }).listen(8080); + +async function stop(): Promise { + try { + // Within the 30 s of Kubernetes: 20 s to drain, 2 s for the cleanups + await shutdown(server, { timeout: 20_000, cleanup_timeout: 2000 }); + } catch (error) { + logger.error("The shutdown has not completed", { error }); + process.exitCode = 1; + } finally { + await logger.flush(); + } +} + +process.once("SIGTERM", () => { + stop().catch(() => { + process.exitCode = 1; + }); +}); +``` + +- Let the process exit on its own once nothing else keeps it alive: close the other resources + (database pools, timers) after `shutdown()`, rather than calling `process.exit()`, which may drop + the last logs. +- Run `node` as the process that receives the signal (e.g. `CMD ["node", "server.js"]` in a + Dockerfile): a shell (the shell form `CMD node server.js`) does not pass `SIGTERM` on. +- A load balancer may still send new connections for a few seconds after `SIGTERM` (e.g. while + Kubernetes removes the endpoints of the pod). A `preStop` hook that sleeps for a few seconds + delays the signal until then. +- `isClosing(server)` tells whether a server is shutting down, e.g. to end long-lived streams + before the drain timeout (see [Server-sent events](#server-sent-events)). + +## Reverse proxies + +binden serves HTTP/1.1 (`createServer()`) and HTTPS (`createSecureServer()`), not HTTP/2 +(`node:http2`, also through its compatibility API). Run it behind a reverse proxy or a load +balancer that terminates TLS and serves HTTP/2 (or HTTP/3) to the clients, and talks HTTP/1.1 to +the server (without a proxy, `createSecureServer()` serves HTTPS itself). + +### Trusting the proxies + +By default, the requests trust no proxy: `request.ip` is the address of the peer (the proxy), +`request.protocol` the one of the connection (`http:` behind a TLS-terminating proxy) and +`request.host` the `Host` header. A client can send any `Forwarded` or `X-Forwarded-*` header, so +trust the headers of the proxies only, by their addresses: a client that reaches the server +directly (e.g. through a network that is not as closed as expected) cannot choose its address, +protocol or host then. + +```typescript +import { BlockList, isIPv6 } from "node:net"; +import { Binden } from "binden"; + +// The addresses of the proxies +const proxies = new BlockList(); +proxies.addSubnet("10.0.0.0", 8); +proxies.addAddress("127.0.0.1"); +proxies.addSubnet("fd00::", 8, "ipv6"); + +const app = new Binden({ + trust_proxy: { + trust: (address) => + proxies.check(address, isIPv6(address) ? "ipv6" : "ipv4"), + // X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host + headers: "x-forwarded", + }, + allowed_hosts: ["example.com", "www.example.com"], + // nginx sets X-Request-Id (below) + request_id: { trust_incoming: true }, +}); +``` + +Trust only the headers that the proxies overwrite (or append to): a proxy that passes on a header +of its client as it is lets the client choose its value. With several proxies (e.g. a CDN in front +of a load balancer), trust the addresses of all of them. The headers are: + +- `"x-forwarded"` for nginx (as below) and an AWS Application Load Balancer, which append the + address of their client to `X-Forwarded-For` and set `X-Forwarded-Proto` (and, for nginx, + `X-Forwarded-Host`; the host is `Host` otherwise); +- `"forwarded"` (the default) for the proxies that send `Forwarded` (RFC 7239, e.g. + `Forwarded: for=192.0.2.60;proto=https;host=example.com`). + +The two families are never mixed. A number trusts the nearest hops instead of their addresses +(`trust: 1` for one proxy in front of the server), and `true` trusts every hop: use them only when +nothing else can reach the server. See [Proxies](https://github.com/binden-js/binden/tree/main/packages/binden#proxies) +for the details of the walk through the hops. + +```nginx +upstream app { + server 127.0.0.1:8080; + keepalive 16; +} + +server { + listen 443 ssl; + http2 on; + server_name example.com www.example.com; + ssl_certificate /etc/ssl/example.com.crt; + ssl_certificate_key /etc/ssl/example.com.key; + # The largest request body (1 MiB by default): see the limits of the parsers below + client_max_body_size 10m; + + location / { + proxy_pass http://app; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Request-Id $request_id; + } +} +``` + +### Allowed hosts + +`request.URL` (and the links that an application builds with it) has the host of the request, +which a client chooses: `allowed_hosts` lists the hosts that the effective host may be (`hostname` +on any port, or `hostname:port`), or is a function of the host that returns a boolean. Another +host gets `400 Bad Request` when the application reads `request.URL` or `request.host` (the +routing does). See [Allowed hosts](https://github.com/binden-js/binden/tree/main/packages/binden#allowed-hosts). + +### Request ids + +Every request has an id (`request.id`), which the logs of binden carry (`request_id` and `id`). +With `request_id: { trust_incoming: true }`, a request keeps the id that it carries +(`X-Request-Id` by default, of 1 to 128 letters, digits, `.`, `_`, `:` and `-`), so that the logs +of the proxy and of the application can be joined: set it only when the proxy sets (or overwrites) +that header, since a client can send any id. With the `request_id` option, the id is sent back in +the response (unless `echo: false`). + +## Request bodies + +Nothing limits the size of a request body by default: set the limits of every parser explicitly, +and align the limit of the proxy (`client_max_body_size` of nginx) with the largest of them. + +### JSON, text and forms + +`@binden/body` reads a body into memory. `limit` counts the bytes once decoded, at every stage of +the decoding (a small compressed body may decode to gigabytes), and `encoded_limit` the bytes as +received (a larger `Content-Length` is refused before the body is read). A body over a limit gets +`413 Content Too Large`, and a form with more than `parameter_limit` (1000) parameters too. + +```typescript +import { BodyParser } from "@binden/body"; + +const MiB = 1024 * 1024; + +// A route that needs larger bodies has a parser of its own, before the +// shared one: a body is parsed once +app.use( + "/import", + new BodyParser({ limit: 50 * MiB, encoded_limit: 10 * MiB }), +); +// JSON, text and forms of every other route (the others are skipped) +app.use(new BodyParser({ limit: MiB, encoded_limit: MiB })); +``` + +### Uploads + +`@binden/multipart` keeps the files in memory by default: each request in progress may then take +as much memory as its body. In production, set every limit, and store the files on the disk (or +stream them to a storage service with `on_file`): + +| Option | What it limits | Suggestion | +| ------------------------ | -------------------------------------------- | ---------- | +| `limit` | The whole body, in bytes, all parts included | 10 MiB | +| `config.limits.fileSize` | A file, in bytes | 5 MiB | +| `config.limits.files` | The count of the files | 10 | +| `config.limits.fields` | The count of the fields | 100 | +| `config.limits.parts` | The count of the parts (fields and files) | 1000 | + +```typescript +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { type Context, Router } from "binden"; +import { type IStoredForm, Multipart } from "@binden/multipart"; + +const MiB = 1024 * 1024; + +const documents = new Multipart({ + limit: 10 * MiB, + config: { + limits: { fileSize: 5 * MiB, files: 10, fields: 100, parts: 1000 }, + }, + // A private directory for each request, under this one + storage: { dir: "/var/tmp/uploads" }, + // What the client declares, not what the file is: check the content too + file_filter: ({ field, mime_type }) => + field === "documents" && mime_type === "application/pdf", +}); + +const save = async (context: Context): Promise => { + const { files } = context.request.body as IStoredForm; + const saved: string[] = []; + for (const file of files.get("documents") ?? []) { + // A name of the application: the name sent by the client is not a path + const target = join("/srv/documents", `${randomUUID()}.pdf`); + await file.persist(target); + saved.push(target); + } + await context.status(201).json({ saved }); +}; + +app.use("/documents", new Router().post(documents, save)); +``` + +- The files are written in a private directory of the request (`0700`), each to a file of its own + (`0600`, named by a random UUID). They are removed once the response is closed, also when the + request is refused or aborted, unless `persist()` has moved them. +- `persist(target)` never replaces a file: a `target` that exists is an `EEXIST` error. It moves a + file with a hard link when `dir` and `target` are on the same file system, and copies it + otherwise. +- `file_filter` checks what a file declares (its field, its name, its media type), which the + client chooses: check the content itself (e.g. its first bytes) before the application uses it. +- A request over a limit gets `413 Content Too Large`, and is not read further. + +## Logging + +```typescript +import { Binden, serializers } from "binden"; +import { Logger } from "@binden/logger"; + +const logger = new Logger({ + level: "info", + // Those of the default logger of binden (e.g. a `body` is redacted) + serializers, + // The fields of the application that may hold secrets + redact: { + paths: ["*.password", "*.token", "user.email"], + censor: "[redacted]", + }, +}); + +const app = new Binden({ + logger, + // One record for each request, at `info`, once its response is closed + access_log: true, + request_id: { trust_incoming: true }, +}); +``` + +- The framework logs its failures as snapshots (`failure`): the type of the error, a known `code`, + its `status`, the `request_id` and where it happened. The message, the stack and the cause of an + error are not logged: they may quote the input of a request (e.g. an error of `JSON.parse()`). + Keep `log_error_details` (and the `error_details` of `@binden/logger`) `false` in production, or + enable them for a while to investigate a problem, knowing what the logs may then hold. +- The default serializers log the method and the path of a request (without its query), and the + status of a response: the headers (with the cookies and the credentials), the query strings and + the bodies stay out of the logs. A path may still hold a secret (e.g. a token in + `/reset/:token`): the access log logs the route template (`/reset/:token`) by default, not the + path. +- `context.log` adds the `trace_id` of the request to every record of a middleware. +- The default logger writes nothing unless `BINDEN_LOG_LEVEL` sets a level: pass a logger with + the `logger` option, or set the variable. +- Call `await logger.flush()` before the process exits (see + [Graceful shutdown](#graceful-shutdown)). + +## Static files + +A reverse proxy or a CDN serves static files best. When the application serves them, take the path +from the request only with the `root` option of `sendFile()`: + +```typescript +import { Binden } from "binden"; + +const root = new URL("./public/", import.meta.url); + +const app = new Binden().route("/assets/*path", (context) => + context.sendFile(context.params.path, { + root, + dotfiles: "ignore", + // The names of the files change with their content + max_age: 31_536_000, + immutable: true, + }), +); +``` + +- With `root`, the path must be a relative path that stays inside the root, once every symbolic + link is resolved: an absolute path, an empty, `.` or `..` segment, a missing file or a file that is + not inside the root gets `404 Not Found`. A dotfile (e.g. `.env`, `.git/config`) gets `404` + (`dotfiles: "ignore"`, the default) or `403` (`"deny"`). +- The guarantee has limits: the root directory and the directories above it must be trusted (not + writable by someone who should not choose what is served), and a race in which an attacker who + can write inside the root replaces a directory of the path by a symbolic link, between the check + and the open, is not prevented. +- Without `root`, the path is trusted as it is: never pass a path from the request then. +- `immutable` (with `max_age`) suits the files whose names change with their content only. + +## Compression + +The defaults of `@binden/compression` suit production: the coding is negotiated with +`Accept-Encoding` (`br`, `gzip`, `deflate`), the responses below 1024 bytes and the types that do +not compress (e.g. compressed images, archives, event streams) are sent as they are, and `br` uses +the quality 4. When the proxy (or a CDN) compresses the responses, leave the compression to it: +compressing in both places only costs time. + +Compression over TLS can leak a secret of a response that also reflects the input of the request +(e.g. a page with a CSRF token and the query of its URL: [BREACH](https://www.breachattack.com/)). +Do not compress such responses: + +```typescript +import { type Context, Router } from "binden"; +import compression, { skipCompression } from "@binden/compression"; + +const account = (context: Context): Promise => { + // A CSRF token, and the search of the user + skipCompression(context.response); + return context.html("
…
"); +}; + +app.use(compression).use("/account", new Router().get(account)); +``` + +## Server-sent events + +A stream of events (`text/event-stream`) is long-lived, so it needs: + +- no deadline: `context.setTimeout(0)`, and the stream ends once `context.signal` is aborted; +- an end before the drain timeout of the shutdown: the stream checks `isClosing(server)`; +- no buffering: nginx buffers the responses unless they have `X-Accel-Buffering: no` (or + `proxy_buffering off`), and closes a stream that is silent for `proxy_read_timeout` (60 s), + which an event (or a comment, `: ping`) sent now and then prevents; +- with compression, a `filter` that compresses it (events are not compressed by default), and + `response.flush()` after each event, so that it is not held in the compressor. + +```typescript +import { setTimeout as sleep } from "node:timers/promises"; +import { Binden, type ContentType, type Context, isClosing } from "binden"; +import { Compression, isCompressible } from "@binden/compression"; + +const compression = new Compression({ + filter: (_context: Context, type: ContentType | null): boolean => + type?.type === "text/event-stream" || isCompressible(type), +}); + +const events = async (context: Context): Promise => { + const { response, signal } = context; + context.setTimeout(0); + context.done = true; + response.setHeader("Content-Type", "text/event-stream"); + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("X-Accel-Buffering", "no"); + for (let id = 1; !signal.aborted && !isClosing(server); id += 1) { + response.write(`id: ${id}\ndata: ${new Date().toISOString()}\n\n`); + // The event reaches the client now, through the compressor + await response.flush(); + await sleep(15_000, undefined, { signal }).catch(() => undefined); + } + response.end(); +}; + +const app = new Binden({ timeout: 30_000 }).use(compression); +app.use("/events", events); +const server = app.createServer({ keepAliveTimeout: 65_000 }).listen(8080); +``` + +## Security headers + +binden sets no security headers, and has no package for them: set them in the first middleware, +so that they apply to every response, the error replies included (an error reply removes only the +headers of the body that it replaces). The +[examples](https://github.com/binden-js/binden/tree/main/examples) have a runnable server with +them. + +```typescript +import { Binden, type Context } from "binden"; + +const policy = [ + "default-src 'self'", + "base-uri 'none'", + "object-src 'none'", + "frame-ancestors 'none'", + "form-action 'self'", +].join("; "); + +const securityHeaders = (context: Context): void => { + context + .setHeader("Content-Security-Policy", policy) + .setHeader("X-Content-Type-Options", "nosniff") + .setHeader("Referrer-Policy", "no-referrer") + .setHeader("Cross-Origin-Opener-Policy", "same-origin"); + // Over HTTPS only (behind a proxy, with `trust_proxy`) + if (context.request.secure) { + context.setHeader( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains", + ); + } +}; + +// See Reverse proxies for `trust_proxy` +const app = new Binden({ + trust_proxy: { trust: 1, headers: "x-forwarded" }, +}).use(securityHeaders); +``` + +- **CSP.** Start with `Content-Security-Policy-Report-Only` to find what the policy would block, + then enforce it. An inline script needs a nonce (a new random value for every response, in the + policy and in the `nonce` attribute of the script) rather than `'unsafe-inline'`. An API that + sends only JSON can send `default-src 'none'; frame-ancestors 'none'`. +- **HSTS.** Browsers ignore `Strict-Transport-Security` over plain HTTP. Start with a short + `max-age` (e.g. `300`), raise it once every subdomain is served over HTTPS, and add `preload` + only to submit the domain to the preload lists. The proxy can set it instead. +- **Cookies.** Name the session cookies with the `__Host-` prefix (`Secure`, the path `/`, no + domain), keep `http_only` (the default) and `same_site` (`Lax` by default, `Strict` where + possible). + +```typescript +import { Cookie } from "binden"; + +context.response.cookies.add( + new Cookie({ + key: "__Host-session", + value: "", + same_site: "Strict", + max_age: 3600, + }), +); +``` + +## Checklist + +- **Node.js**: `>=24.14.1`; `headersTimeout`, `requestTimeout` and `keepAliveTimeout` set, the + latter longer than the idle timeout of the proxy. +- **Deadlines**: `timeout` set (e.g. 30 s); `context.setTimeout()` for the slow routes and + `context.setTimeout(0)` for the streams; `context.signal` passed to the work of the requests. +- **Shutdown**: `shutdown(server)` on `SIGTERM`, within the grace period of the platform; `node` + receives the signal; the logs are flushed. +- **Proxy**: TLS and HTTP/2 at the proxy; `trust_proxy` by the addresses of the proxies, with the + headers that they set (`"x-forwarded"` or `"forwarded"`); `allowed_hosts`; + `request_id.trust_incoming` only when the proxy sets the id. +- **Bodies**: `limit` and `encoded_limit` for `@binden/body`; `limit` and `config.limits` for + `@binden/multipart`, with the disk or stream storage; the limit of the proxy aligned. +- **Logs**: a logger at `info`; `access_log: true`; `log_error_details: false`; the secrets of the + application redacted. +- **Files**: `sendFile()` with `root` for any path from a request, from a trusted root. +- **Compression**: the defaults; `skipCompression()` for the responses that mix secrets and input; + server-sent events compressed only with a `filter` and `flush()`. +- **Headers**: `Content-Security-Policy`, `Strict-Transport-Security` (over HTTPS), + `X-Content-Type-Options`, `Referrer-Policy`; `__Host-` cookies. diff --git a/typedoc.json b/typedoc.json index 3c4f661d..5917667b 100644 --- a/typedoc.json +++ b/typedoc.json @@ -14,18 +14,21 @@ } } }, + "projectDocuments": ["MIGRATION-4.md", "PRODUCTION.md"], "hideGenerator": true, "gitRevision": "main", "highlightLanguages": [ "bash", "console", "css", + "diff", "html", "http", "javascript", "json", "jsonc", "json5", + "nginx", "tsx", "typescript", "yaml" From e97d7c75d184a49e9dcc4e8dda3ef3cd00d6f83f Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 02:58:08 +0300 Subject: [PATCH 175/675] docs: add a contributing guide and the code owners The repository had no guide for contributors: CONTRIBUTING.md tells how to set up the workspaces, what a change needs (a failing test, 100% coverage, JSDoc, validated options, the README), how the commits make the releases, and what CI checks. CODEOWNERS asks the maintainer to review every pull request, which the branch protection of main can require. Co-Authored-By: Claude Opus 5.5 --- .github/CODEOWNERS | 2 ++ CONTRIBUTING.md | 77 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 CONTRIBUTING.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..2acd700f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# The maintainers review every change of the repository +* @vansergen diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..2cb7ccf5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,77 @@ +# Contributing + +Thank you for helping with binden. This repository holds `binden` and its official packages +(`@binden/logger`, `@binden/body`, `@binden/compression` and `@binden/multipart`), released +together with one version. + +## Before you start + +- Report a bug or propose a feature with an [issue](https://github.com/binden-js/binden/issues/new/choose). + For a significant change, open an issue first, so that the problem and the solution can be + discussed before the work starts. +- Never report a vulnerability in a public issue: see the [security policy](SECURITY.md). +- Everyone who takes part follows the [code of conduct](CODE_OF_CONDUCT.md). + +## Set-up + +The repository needs Node.js `>=24.14.1` and npm `>=11.11.0` (CI tests Node.js 24.14.1, the latest +24 and 26, on Linux, Windows and macOS). + +```bash +git clone https://github.com/binden-js/binden.git +cd binden +npm ci # installs and builds every workspace, and sets up the Git hooks +``` + +The [README](README.md#development) lists the commands. The packages use each other through their +compiled `dist/`: run `npm run build` after changing a package that others depend on (`binden` +depends on `@binden/logger`, and the middlewares on `binden`). + +## Changes + +- **Tests.** Every change of behaviour comes with a test (`node:test`, in the `test/` directory of + the package) that fails without it. The coverage stays at 100% of the lines, branches, functions + and statements of every package (`npm run coverage`). A test is bounded (e.g. with + `AbortSignal.timeout()`): a hang must fail, not block the runner. +- **Types.** The code is strict TypeScript (with `exactOptionalPropertyTypes`: an option is declared + as `name?: T | undefined`). Every exported symbol has a JSDoc comment, which the + [API documentation](https://binden-js.github.io/binden/) shows (`npm run docs:build` fails on a + warning). +- **Options.** A public option is validated when it is given: an invalid one throws a `TypeError` + (or a `RangeError` for a value out of its range) in the constructor. +- **Documentation.** A change of the API or of a behaviour updates the README of its package. The + TypeScript examples of the READMEs are type checked (`npm run consumer:check`). +- **Style.** ESLint and Prettier check the code (`npm run eslint`, `npm run prettier`); the + pre-commit hook fixes the staged files. + +Before opening a pull request, run everything that CI runs: + +```bash +npm run ci:all +``` + +## Commits + +The commits follow [Conventional Commits](https://conventionalcommits.org), checked by commitlint +(the commit hook and CI): they make the next version and its release notes. + +- The type is one of `feat` (a minor release), `fix`, `perf`, `revert`, `build`, `dependencies` and + `peerDependencies` (a patch release), or `docs`, `test`, `refactor`, `style`, `ci`, `chore`, + `devDependencies` and `metadata` (no release). See [`commitlint.config.js`](commitlint.config.js). +- The subject says what changes, and names the package that it concerns, e.g. + `fix: parse Accept-Encoding codings case-insensitively in binden`. +- The body explains why, in lines of at most 100 characters. +- A breaking change has a `!` after the type and a `BREAKING CHANGE:` footer that tells the users + what they must change: it is a major release, and its footer goes to the release notes. + +Each commit is one logical change, and builds and passes the tests on its own. + +## Pull requests + +- Fill in the template, and link the issue that the pull request resolves. +- CI must pass: the lint job (formatting, commits, ESLint, the tooling tests, the release rehearsal, + the packed files, the consumer check and the docs), the tests on every supported Node.js, on + Windows and macOS, CodeQL and the dependency review. +- A maintainer ([`CODEOWNERS`](.github/CODEOWNERS)) reviews every pull request. + +Merging into `main` releases the packages: see [Releases](README.md#releases). From af604a78b857b38c9e575a6973b0d8c926eca658 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 02:58:09 +0300 Subject: [PATCH 176/675] docs: state the supported versions and the response times of the security policy A reporter had no idea when to expect an answer, nor which versions get fixes now that the packages share one version. The policy supports the latest 4.x release, and commits to acknowledge a report within 7 days, to assess it within 14 and to release a fix (or agree on a disclosure plan) within 90, then to publish an advisory. Co-Authored-By: Claude Opus 5.5 --- SECURITY.md | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 82c6bc47..a588e6d8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,11 +2,36 @@ ## Supported versions -Only the latest release of the packages of this repository (`binden`, `@binden/logger`, -`@binden/body`, `@binden/compression` and `@binden/multipart`) gets security fixes. +All the packages of this repository (`binden`, `@binden/logger`, `@binden/body`, +`@binden/compression` and `@binden/multipart`) share one version. Security fixes are released for +the latest 4.x release only: update every package to it. + +| Version | Supported | +| -------------------------------------------------------------------- | --------- | +| 4.x (the latest release) | Yes | +| Earlier releases (`binden` 2.x and below, `@binden/*` 3.x and below) | No | ## Reporting a vulnerability -Please do not report vulnerabilities in public issues. Report them privately through -[GitHub](https://github.com/binden-js/binden/security/advisories/new) (Security → Report a -vulnerability), with the affected package and version, and the steps to reproduce. +Please do not report vulnerabilities in public issues, pull requests or discussions. Report them +privately through [GitHub](https://github.com/binden-js/binden/security/advisories/new) (Security +→ Report a vulnerability), with: + +- the affected package and version, and the version of Node.js; +- the impact, and the steps to reproduce it (e.g. a minimal server and the request that it gets); +- a fix, if you have one in mind. + +## What to expect + +- An acknowledgement of the report within 7 days. +- An assessment (whether it is a vulnerability, its severity and the affected versions) within 14 + days of the report. +- A fixed release, or a plan for the disclosure agreed with you, within 90 days of the report. + +Once the fix is released, a [security advisory](https://github.com/binden-js/binden/security/advisories) +describes the vulnerability (with a CVE when it applies), and credits the reporter unless they +prefer otherwise. + +The limits that the documentation states (e.g. the trust boundary of `sendFile()` with `root`, or +a server without the limits and the timeouts of the [production guide](PRODUCTION.md)) are not +vulnerabilities by themselves; a way around them is. From 08e48cf255c12552a83466a617527443c2310d50 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 02:58:20 +0300 Subject: [PATCH 177/675] docs: update the issue and pull request templates The bug report asked for Node.js 16 and an npm version like 16.4.2, and had no warning against public reports of vulnerabilities. It now asks for the supported versions and for the packages in lockstep. config.yml turns off blank issues and points vulnerabilities to the private reporting of GitHub. The pull request template moves to .github/pull_request_template.md, the single default template, with a checklist of what a change needs (tests, the README, Conventional Commits with the BREAKING CHANGE footer, npm run ci:all). Co-Authored-By: Claude Opus 5.5 --- .github/ISSUE_TEMPLATE/bug_report.md | 17 ++++++++++------- .github/ISSUE_TEMPLATE/config.yml | 5 +++++ .github/ISSUE_TEMPLATE/feature_request.md | 2 +- .../PULL_REQUEST_TEMPLATE.md | 8 -------- .github/pull_request_template.md | 12 ++++++++++++ 5 files changed, 28 insertions(+), 16 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/PULL_REQUEST_TEMPLATE/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/pull_request_template.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 1e4253e9..ba836a64 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,18 +1,20 @@ --- name: Bug report -about: Create a report to help us improve +about: Report a bug of binden or of one of the @binden packages title: "[BUG]" labels: bug --- + + **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** -Steps to reproduce the behavior (add some code): +Steps to reproduce the behavior (a minimal server, and the request that it gets): ```typescript -import * as binden from "binden"; +import { Binden } from "binden"; // more code to reproduce the problem ``` @@ -21,9 +23,10 @@ A clear and concise description of what you expected to happen. **Please complete the following information:** -- Node.js version: [e.g. v16.4.2] -- NPM version: [e.g. 16.4.2] -- Package and version [e.g. `binden` 4.0.0, `@binden/body` 4.0.0] +- Node.js version: [e.g. 24.14.1] +- npm version: [e.g. 11.11.0] +- Packages and versions: [e.g. `binden` 4.0.0, `@binden/body` 4.0.0] +- Operating system: [e.g. Ubuntu 24.04] **Additional context** -Add any other context about the problem here. +Add any other context about the problem here (e.g. the reverse proxy in front of the server). diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..5a184afd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Report a vulnerability + url: https://github.com/binden-js/binden/security/advisories/new + about: Report a vulnerability privately, never in a public issue (see SECURITY.md). diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 81c9a3a1..5d48a2e9 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,6 +1,6 @@ --- name: Feature request -about: Suggest an idea for this project +about: Suggest an idea for binden or one of the @binden packages title: "[FEATURE-REQUEST]" labels: enhancement --- diff --git a/.github/PULL_REQUEST_TEMPLATE/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index be2fb17e..00000000 --- a/.github/PULL_REQUEST_TEMPLATE/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,8 +0,0 @@ -## Checklist: - -- [ ] I have run `npm run ci:all` locally and all tests are passing. -- [ ] If this is a significant change, an issue has already been created where the problem / solution was discussed: [N/A, or add link to issue here] - -## Description - - diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..a3fe8b44 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,12 @@ +## Description + + + +## Checklist + +- [ ] An issue has discussed the problem and the solution (for a significant change): +- [ ] Every change of behaviour has a test, and the coverage stays at 100%. +- [ ] The README of the package (and the JSDoc of the exported symbols) describes the change. +- [ ] The commits follow [Conventional Commits](https://conventionalcommits.org), with a `!` and a + `BREAKING CHANGE:` footer for a breaking change (see [CONTRIBUTING.md](https://github.com/binden-js/binden/blob/main/CONTRIBUTING.md)). +- [ ] `npm run ci:all` passes locally. From ed600c113c7978be379267c594bd2b02687281bc Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 02:58:21 +0300 Subject: [PATCH 178/675] docs: fix the heading of the 2.0.3 release in the changelog of multipart The heading of 2.0.3 was a element, unlike the other releases: it is now a link to its comparison with 2.0.2, as the headings of the other packages are. Co-Authored-By: Claude Opus 5.5 --- packages/multipart/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/multipart/CHANGELOG.md b/packages/multipart/CHANGELOG.md index b29ff837..ed9b2c2b 100644 --- a/packages/multipart/CHANGELOG.md +++ b/packages/multipart/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 2.0.3 (2025-06-29) +## [2.0.3](https://github.com/binden-js/multipart/compare/v2.0.2...v2.0.3) (2025-06-29) - Merge branch 'main' into dependabot/npm_and_yarn/binden/tsconfig-3.0.0 ([9f25869](https://github.com/binden-js/multipart/commit/9f25869)) - Merge pull request #104 from binden-js/dependabot/npm_and_yarn/husky-9.1.7 ([07272cb](https://github.com/binden-js/multipart/commit/07272cb)), closes [#104](https://github.com/binden-js/multipart/issues/104) From dc6658f68cb100ad824454fef7763ed405600d2f Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 02:58:22 +0300 Subject: [PATCH 179/675] docs: link the guides from the README The README only linked the API documentation: a Documentation section lists it with the migration guide, the production guide, the contributing guide and the security policy, which TypeDoc links to their pages on the documentation site. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5403ef34..2bd6a021 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,15 @@ the same version. | [`@binden/compression`](packages/compression) | Compression middleware | MIT | | [`@binden/multipart`](packages/multipart) | `multipart/form-data` parser middleware | MIT | -API documentation: +## Documentation + +- [API documentation](https://binden-js.github.io/binden/) of every package, and the README of + each package (linked above) for its usage; +- [Migrating to binden 4](MIGRATION-4.md): from `binden` 2 and the earlier releases of the + `@binden/*` packages; +- [Running binden in production](PRODUCTION.md): timeouts, graceful shutdown, reverse proxies, + limits, logging, static files, compression, server-sent events and security headers; +- [Contributing](CONTRIBUTING.md), and [reporting a vulnerability](SECURITY.md). ## Development From e83491d57fbf46d8564ca113c90d5a297042bb77 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 02:56:13 +0300 Subject: [PATCH 180/675] build: refuse tests, secrets and local paths of source maps in the packed files The packed files are compared with scripts/pack-files.json only: `pack-check --update` would accept a test, a fixture or the test certificate of binden into the list, and nothing read the content of a file. The packages are going to publish their sources and source maps, whose paths could name the machine that built them. scripts/pack-rules.mjs refuses, whatever the list says, the files of test, fixtures, cert and coverage directories, tests, certificates and keys, environment and npm configuration files and build artefacts. A source map must have relative sources inside the package that are published (no sourceRoot), and a generated file may only link to a published map. The check reads the files of the workspaces (`npm pack --dry-run`) or of the prepared archives (`--tarballs`), and `--update` writes nothing while a rule is broken. Co-Authored-By: Claude Opus 5.5 --- scripts/pack-check.mjs | 68 ++++++++-- scripts/pack-check.test.mjs | 151 +++++++++++++++++++++- scripts/pack-rules.mjs | 141 ++++++++++++++++++++ scripts/pack-rules.test.mjs | 248 ++++++++++++++++++++++++++++++++++++ 4 files changed, 591 insertions(+), 17 deletions(-) create mode 100644 scripts/pack-rules.mjs create mode 100644 scripts/pack-rules.test.mjs diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs index 4bb784e3..558f0dff 100644 --- a/scripts/pack-check.mjs +++ b/scripts/pack-check.mjs @@ -1,7 +1,10 @@ #!/usr/bin/env node // Checks the files that the tarball of every workspace contains against // `scripts/pack-files.json`: a file that is missing (e.g. a module not built) or that -// should not be published (e.g. tests, sources or secrets) fails the check. +// should not be published (e.g. tests, sources or secrets) fails the check. On top of +// the list, the rules of `scripts/pack-rules.mjs` refuse tests, fixtures, certificates +// and secrets whatever the list says, and source maps that do not point to the +// published sources with relative paths. // // node scripts/pack-check.mjs `npm pack --dry-run` of the workspaces, // which must be built before (their @@ -10,17 +13,32 @@ // pack ` prepared, without packing // again (their integrity is checked) // --update write the current files as the expected -// ones -import { compareFiles, inspectTarball, readRelease } from "./tarballs.mjs"; +// ones (unless they break the rules) +import { compareFiles, readRelease, readTarball } from "./tarballs.mjs"; import { join, resolve } from "node:path"; import { readFileSync, writeFileSync } from "node:fs"; import { npm } from "./npm.mjs"; +import { packageErrors } from "./pack-rules.mjs"; const root = join(import.meta.dirname, ".."); const expectedFile = join(root, "scripts", "pack-files.json"); const args = process.argv.slice(2); const tarballs = args.indexOf("--tarballs"); +const readJSON = (path) => JSON.parse(readFileSync(path, "utf8")); + +// The directory of each workspace, by package name +function workspaceDirs() { + const { workspaces = [] } = readJSON(join(root, "package.json")); + return new Map( + workspaces.map((dir) => [ + readJSON(join(root, dir, "package.json")).name, + join(root, dir), + ]), + ); +} + +// The packed files of each package, and how to read them: { name: { paths, read } } function packedFiles() { const packed = JSON.parse( npm(["pack", "--dry-run", "--json", "--workspaces", "--ignore-scripts"], { @@ -29,10 +47,21 @@ function packedFiles() { stdio: ["ignore", "pipe", "inherit"], }), ); + let dirs = null; return Object.fromEntries( packed.map(({ name, files }) => [ name, - files.map(({ path }) => path).sort(), + { + paths: files.map(({ path }) => path).sort(), + read: (path) => { + dirs ??= workspaceDirs(); + const dir = dirs.get(name); + if (typeof dir === "undefined") { + throw new Error(`${name} is not a workspace`); + } + return readFileSync(join(dir, path)); + }, + }, ]), ); } @@ -42,27 +71,42 @@ function preparedFiles(dir) { throw new Error("Usage: scripts/pack-check.mjs --tarballs "); } return Object.fromEntries( - readRelease(resolve(dir)).map(({ name, path }) => [ - name, - inspectTarball(readFileSync(path)).files, - ]), + readRelease(resolve(dir)).map(({ name, path }) => { + const files = readTarball(readFileSync(path)); + return [ + name, + { paths: [...files.keys()].sort(), read: (file) => files.get(file) }, + ]; + }), ); } -const actual = +const packages = tarballs === -1 ? packedFiles() : preparedFiles(args[tarballs + 1]); +const actual = Object.fromEntries( + Object.entries(packages).map(([name, { paths }]) => [name, paths]), +); +const refused = Object.entries(packages).flatMap(([name, { paths, read }]) => + packageErrors(paths, read).map((error) => `${name}: ${error}`), +); +const rules = `The packed files break the rules of scripts/pack-rules.mjs:\n ${refused.join("\n ")}`; if (args.includes("--update")) { + if (refused.length > 0) { + throw new Error(rules); + } writeFileSync(expectedFile, `${JSON.stringify(actual, null, 2)}\n`); process.stderr.write(`[pack] ${expectedFile} updated\n`); } else { - const expected = JSON.parse(readFileSync(expectedFile, "utf8")); - const errors = compareFiles(expected, actual); + const errors = compareFiles(readJSON(expectedFile), actual); if (errors.length > 0) { throw new Error( - `The packed files differ from scripts/pack-files.json (run \`node scripts/pack-check.mjs --update\` if this is expected):\n ${errors.join("\n ")}`, + `The packed files differ from scripts/pack-files.json (run \`node scripts/pack-check.mjs --update\` if this is expected):\n ${errors.join("\n ")}${refused.length > 0 ? `\n${rules}` : ""}`, ); } + if (refused.length > 0) { + throw new Error(rules); + } process.stderr.write( `[pack] ${Object.keys(actual).length} workspaces with the expected files\n`, ); diff --git a/scripts/pack-check.test.mjs b/scripts/pack-check.test.mjs index 70281908..f7e48a12 100644 --- a/scripts/pack-check.test.mjs +++ b/scripts/pack-check.test.mjs @@ -8,9 +8,9 @@ import { rmSync, writeFileSync, } from "node:fs"; -import { deepEqual, equal, match } from "node:assert/strict"; +import { deepEqual, doesNotMatch, equal, match } from "node:assert/strict"; +import { dirname, join } from "node:path"; import { integrity, manifestFile } from "./tarballs.mjs"; -import { join } from "node:path"; import { npmTarball } from "./fixtures/tar.mjs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; @@ -20,6 +20,24 @@ import { tmpdir } from "node:os"; const fakeNpm = `if (process.argv[2] !== "pack") process.exit(1); process.stdout.write(process.env.FAKE_NPM_PACK);\n`; +// A source map of `sources`, with a relative `sourceRoot` as `tsc` writes it +const map = (sources, extra = {}) => + JSON.stringify({ + version: 3, + sources, + sourceRoot: "", + mappings: "", + ...extra, + }); + +// The files of a workspace whose maps point to its published sources +const mapped = { + "dist/index.js": "export {};\n//# sourceMappingURL=index.js.map\n", + "dist/index.js.map": map(["../index.ts"]), + "index.ts": "export {};\n", + "package.json": "{}", +}; + const packed = (files) => JSON.stringify( Object.entries(files).map(([name, paths]) => ({ @@ -46,14 +64,35 @@ describe("scripts/pack-check.mjs", () => { }, ); - // Archives prepared by `scripts/release.mjs pack` (only the parts that are checked) + // A workspace `packages/` of the root with `files` ({ path: content }) + const workspace = (name, files) => { + writeFileSync( + join(root, "package.json"), + JSON.stringify({ workspaces: [`packages/${name}`] }), + ); + for (const [path, content] of Object.entries({ + ...files, + "package.json": JSON.stringify({ name }), + })) { + mkdirSync(join(root, "packages", name, dirname(path)), { + recursive: true, + }); + writeFileSync(join(root, "packages", name, path), content); + } + }; + + // Archives prepared by `scripts/release.mjs pack` (only the parts that are checked): + // the files of each package as a list of paths (`{}` as their content), or as + // { path: content } const prepare = (files) => { const dir = join(root, "release"); mkdirSync(dir); const packages = Object.entries(files).map(([name, paths]) => { const file = `${name}-1.0.0.tgz`; const data = npmTarball( - Object.fromEntries(paths.map((path) => [path, "{}"])), + Array.isArray(paths) + ? Object.fromEntries(paths.map((path) => [path, "{}"])) + : paths, ); writeFileSync(join(dir, file), data); return { @@ -71,7 +110,12 @@ describe("scripts/pack-check.mjs", () => { beforeEach(() => { root = mkdtempSync(join(tmpdir(), "binden-pack-check-")); mkdirSync(join(root, "scripts")); - for (const script of ["pack-check.mjs", "npm.mjs", "tarballs.mjs"]) { + for (const script of [ + "pack-check.mjs", + "pack-rules.mjs", + "npm.mjs", + "tarballs.mjs", + ]) { copyFileSync( join(import.meta.dirname, script), join(root, "scripts", script), @@ -82,6 +126,7 @@ describe("scripts/pack-check.mjs", () => { join(root, "scripts/pack-files.json"), JSON.stringify({ a: ["LICENSE", "dist/index.js", "package.json"] }), ); + workspace("a", { "dist/index.js": "export {};\n" }); }); afterEach(() => { @@ -96,6 +141,71 @@ describe("scripts/pack-check.mjs", () => { match(stderr, /1 workspaces with the expected files/u); }); + it("refuses tests, certificates and secrets even when they are expected", () => { + const files = { + a: ["dist/test/a.test.js", "key.pem", ".npmrc", "README.md"], + }; + writeFileSync(join(root, "scripts/pack-files.json"), JSON.stringify(files)); + const { status, stderr } = check(files); + equal(status, 1); + match(stderr, /break the rules of scripts\/pack-rules\.mjs/u); + match(stderr, /a: dist\/test\/a\.test\.js: a file of a test\/ directory/u); + match(stderr, /a: key\.pem: a certificate or a key must not be published/u); + match( + stderr, + /a: \.npmrc: an npm configuration file must not be published/u, + ); + doesNotMatch(stderr, /README/u); + doesNotMatch(stderr, /differ from scripts\/pack-files\.json/u); + }); + + it("reports the rules together with the differences", () => { + const { status, stderr } = check({ a: ["LICENSE", "a.pem"] }); + equal(status, 1); + match(stderr, /a: unexpected a\.pem/u); + match(stderr, /a: missing dist\/index\.js/u); + match(stderr, /a: a\.pem: a certificate or a key must not be published/u); + }); + + it("checks the source maps of the workspaces on disk", () => { + workspace("a", mapped); + const files = { a: Object.keys(mapped) }; + writeFileSync(join(root, "scripts/pack-files.json"), JSON.stringify(files)); + const passed = check(files); + equal(passed.status, 0, passed.stderr); + + workspace("a", { + ...mapped, + "dist/index.js.map": map(["/Users/dev/binden/index.ts"]), + }); + const { status, stderr } = check(files); + equal(status, 1); + match( + stderr, + /a: dist\/index\.js\.map: source "\/Users\/dev\/binden\/index\.ts" is not a relative path inside the package/u, + ); + }); + + it("fails to read the files of a package that is not a workspace", () => { + workspace("a", mapped); + const { status, stderr } = check({ b: ["dist/index.js.map"] }); + equal(status, 1); + match(stderr, /b is not a workspace/u); + }); + + it("--update writes nothing when the files break the rules", () => { + const { status, stderr } = check( + { a: ["LICENSE", "dist/index.js", "fixtures/a.json", "package.json"] }, + "--update", + ); + equal(status, 1); + match(stderr, /a: fixtures\/a\.json: a file of a fixtures\/ directory/u); + deepEqual( + JSON.parse(readFileSync(join(root, "scripts/pack-files.json"), "utf8")), + { a: ["LICENSE", "dist/index.js", "package.json"] }, + ); + }); + it("fails with an unexpected or a missing file", () => { const { status, stderr } = check({ a: ["LICENSE", "dist/test/a.test.js", "package.json"], @@ -144,6 +254,37 @@ describe("scripts/pack-check.mjs", () => { match(stderr, /b: unexpected package\.json/u); }); + it("checks the source maps inside the archives", () => { + writeFileSync( + join(root, "scripts/pack-files.json"), + JSON.stringify({ a: Object.keys(mapped).sort() }), + ); + const passed = check({}, "--tarballs", prepare({ a: mapped })); + equal(passed.status, 0, passed.stderr); + + rmSync(join(root, "release"), { force: true, recursive: true }); + const dir = prepare({ + a: { ...mapped, "dist/index.js.map": map(["../../src/index.ts"]) }, + }); + const { status, stderr } = check({}, "--tarballs", dir); + equal(status, 1); + match( + stderr, + /a: dist\/index\.js\.map: source "\.\.\/\.\.\/src\/index\.ts" is not a relative path inside the package/u, + ); + }); + + it("refuses certificates inside the archives even when they are expected", () => { + const files = ["LICENSE", "package.json", "test/cert/key.pem"]; + writeFileSync( + join(root, "scripts/pack-files.json"), + JSON.stringify({ a: files }), + ); + const { status, stderr } = check({}, "--tarballs", prepare({ a: files })); + equal(status, 1); + match(stderr, /a: test\/cert\/key\.pem: a file of a test\/ directory/u); + }); + it("fails when an archive changed after it was prepared", () => { const dir = prepare({ a: ["package.json", "LICENSE", "dist/index.js"] }); appendFileSync(join(dir, "a-1.0.0.tgz"), "x"); diff --git a/scripts/pack-rules.mjs b/scripts/pack-rules.mjs new file mode 100644 index 00000000..e8f426ab --- /dev/null +++ b/scripts/pack-rules.mjs @@ -0,0 +1,141 @@ +// The rules for the content of a published package, on top of its exact file list +// (`scripts/pack-files.json`, which `scripts/pack-check.mjs --update` rewrites from what +// is packed): files that must never be published whatever the list says (tests, +// fixtures, certificates and keys, environment and npm configuration files, build +// artefacts), and source maps that must point to the published sources of the package +// with relative paths (never to the absolute paths of the machine that built it). +import { posix } from "node:path"; + +const deniedDirectories = new Set([ + ".git", + "__tests__", + "cert", + "certs", + "coverage", + "fixtures", + "node_modules", + "test", + "tests", +]); + +const deniedFiles = [ + [/\.(?:test|spec)\.[^/]+$/u, "a test"], + [/\.(?:cer|crt|csr|der|key|p12|pem|pfx)$/iu, "a certificate or a key"], + [/(?:^|\/)\.env(?:\.[^/]*)?$/u, "an environment file"], + [/(?:^|\/)\.npmrc$/u, "an npm configuration file"], + [/\.(?:log|tgz|tsbuildinfo)$/u, "a build artefact"], +]; + +// A URL scheme, or a Windows drive (`C:`) +const scheme = /^[A-Za-z][\d+.A-Za-z-]*:/u; + +// The comment that links a generated file to its source map, at its end +const mappingURL = /\/\/[#@] sourceMappingURL=(?\S*)\s*$/u; + +// The generated files that may link to a source map +const generated = /\.(?:[cm]?js|d\.[cm]?ts)$/u; + +/** Why `path` (relative to the root of the package) must not be published, or null */ +export function deniedReason(path) { + const directory = path + .split("/") + .slice(0, -1) + .find((name) => deniedDirectories.has(name)); + if (typeof directory !== "undefined") { + return `a file of a ${directory}/ directory`; + } + return deniedFiles.find(([pattern]) => pattern.test(path))?.[1] ?? null; +} + +/** + * The path that `target`, relative to the file `from`, points to inside the package, + * or null when it is not a relative path (absolute, a URL, a Windows path) or when it + * points outside of the package + */ +export function resolveInside(from, target) { + if ( + target === "" || + target.startsWith("/") || + target.includes("\\") || + scheme.test(target) + ) { + return null; + } + const path = posix.normalize(posix.join(posix.dirname(from), target)); + return path === "." || path === ".." || path.startsWith("../") ? null : path; +} + +function parseJSON(content) { + try { + return JSON.parse(content.toString("utf8")); + } catch { + return null; + } +} + +function mapErrors(path, content, files) { + const map = parseJSON(content); + if (map?.version !== 3 || !Array.isArray(map.sources)) { + return [`${path}: invalid source map`]; + } + const errors = []; + if (!files.has(path.slice(0, -".map".length))) { + errors.push(`${path}: its generated file is not published`); + } + if (typeof map.sourceRoot !== "undefined" && map.sourceRoot !== "") { + errors.push( + `${path}: sourceRoot ${JSON.stringify(map.sourceRoot)} (the sources must be relative to the map)`, + ); + } + if (map.sources.length === 0) { + errors.push(`${path}: no sources`); + } + for (const source of map.sources) { + const target = + typeof source === "string" ? resolveInside(path, source) : null; + if (target === null) { + errors.push( + `${path}: source ${JSON.stringify(source)} is not a relative path inside the package`, + ); + } else if (!files.has(target)) { + errors.push(`${path}: source ${target} is not published`); + } + } + return errors; +} + +function mappingURLErrors(path, content, files) { + const url = mappingURL.exec(content.toString("utf8"))?.groups?.url; + if (typeof url === "undefined") { + return []; + } + const target = resolveInside(path, url); + if (target === null || !target.endsWith(".map")) { + return [ + `${path}: sourceMappingURL ${JSON.stringify(url)} is not a relative path to a map inside the package`, + ]; + } + return files.has(target) + ? [] + : [`${path}: sourceMappingURL ${target} is not published`]; +} + +/** + * The files of a package (`paths`, relative to its root) that break these rules; + * `read(path)` returns the content of a file (a `Buffer`) + */ +export function packageErrors(paths, read) { + const files = new Set(paths); + const errors = []; + for (const path of paths) { + const reason = deniedReason(path); + if (reason !== null) { + errors.push(`${path}: ${reason} must not be published`); + } else if (path.endsWith(".map")) { + errors.push(...mapErrors(path, read(path), files)); + } else if (generated.test(path)) { + errors.push(...mappingURLErrors(path, read(path), files)); + } + } + return errors; +} diff --git a/scripts/pack-rules.test.mjs b/scripts/pack-rules.test.mjs new file mode 100644 index 00000000..2dd60c0a --- /dev/null +++ b/scripts/pack-rules.test.mjs @@ -0,0 +1,248 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { deniedReason, packageErrors, resolveInside } from "./pack-rules.mjs"; +import { describe, it } from "node:test"; + +const map = (sources, extra = {}) => + JSON.stringify({ version: 3, sources, mappings: "", ...extra }); + +// The errors of a package made of `files` ({ path: content }) +const errors = (files) => + packageErrors(Object.keys(files), (path) => Buffer.from(files[path])); + +describe("scripts/pack-rules.mjs", () => { + describe("deniedReason()", () => { + it("refuses tests, fixtures, certificates and secrets", () => { + for (const [path, reason] of [ + ["dist/test/index.test.js", "a file of a test/ directory"], + ["test/helpers.ts", "a file of a test/ directory"], + ["src/tests/a.ts", "a file of a tests/ directory"], + ["src/__tests__/a.ts", "a file of a __tests__/ directory"], + ["test-data/fixtures/a.json", "a file of a fixtures/ directory"], + ["cert/a.txt", "a file of a cert/ directory"], + ["certs/a.txt", "a file of a certs/ directory"], + ["coverage/lcov.info", "a file of a coverage/ directory"], + ["node_modules/a/index.js", "a file of a node_modules/ directory"], + [".git/config", "a file of a .git/ directory"], + ["src/a.test.ts", "a test"], + ["dist/a.spec.js", "a test"], + ["key.pem", "a certificate or a key"], + ["dist/server.KEY", "a certificate or a key"], + ["a.crt", "a certificate or a key"], + ["a.cer", "a certificate or a key"], + ["a.csr", "a certificate or a key"], + ["a.der", "a certificate or a key"], + ["a.p12", "a certificate or a key"], + ["a.pfx", "a certificate or a key"], + [".env", "an environment file"], + ["dist/.env.production", "an environment file"], + [".npmrc", "an npm configuration file"], + ["src/.npmrc", "an npm configuration file"], + ["npm-debug.log", "a build artefact"], + ["binden-1.0.0.tgz", "a build artefact"], + ["tsconfig.tsbuildinfo", "a build artefact"], + ]) { + equal(deniedReason(path), reason, path); + } + }); + + it("accepts the files of a package", () => { + for (const path of [ + "LICENSE", + "README.md", + "MIGRATION-4.md", + "package.json", + "index.ts", + "src/headers/content-type.ts", + "dist/index.js", + "dist/index.js.map", + "dist/index.d.ts", + "dist/index.d.ts.map", + "dist/src/testing.js", + "dist/src/attest.js", + "src/certificate.ts", + "src/environment.ts", + "dist/src/.envelope.js", + ]) { + equal(deniedReason(path), null, path); + } + }); + }); + + describe("resolveInside()", () => { + it("resolves relative paths inside the package", () => { + equal(resolveInside("dist/index.js.map", "../index.ts"), "index.ts"); + equal( + resolveInside("dist/src/headers/a.js.map", "../../../src/headers/a.ts"), + "src/headers/a.ts", + ); + equal(resolveInside("index.js", "index.js.map"), "index.js.map"); + equal(resolveInside("dist/a.js", "./a.js.map"), "dist/a.js.map"); + }); + + it("refuses absolute paths, URLs and paths outside the package", () => { + for (const target of [ + "", + "/Users/dev/binden/index.ts", + "C:/dev/binden/index.ts", + "C:\\dev\\binden\\index.ts", + "..\\index.ts", + "file:///dev/binden/index.ts", + "webpack://binden/index.ts", + "data:application/json;base64,e30=", + "../../index.ts", + "../../../packages/binden/index.ts", + "..", + "../dist/..", + ]) { + equal(resolveInside("dist/index.js.map", target), null, target); + } + }); + }); + + describe("packageErrors()", () => { + it("accepts maps that point to the published sources", () => { + deepEqual( + errors({ + "index.ts": "export {};", + "src/a.ts": "export {};", + "dist/index.js": "export {};\n//# sourceMappingURL=index.js.map\n", + "dist/index.js.map": map(["../index.ts"], { + file: "index.js", + sourceRoot: "", + }), + "dist/index.d.ts": "export {};\n//# sourceMappingURL=index.d.ts.map", + "dist/index.d.ts.map": map(["../index.ts"]), + "dist/src/a.js": "//# sourceMappingURL=a.js.map", + "dist/src/a.js.map": map(["../../src/a.ts"]), + "dist/plain.js": "export {};", + "package.json": "{}", + }), + [], + ); + }); + + it("refuses files that must not be published, without reading them", () => { + deepEqual( + packageErrors(["dist/test/a.js.map", "secret.pem"], () => { + throw new Error("read"); + }), + [ + "dist/test/a.js.map: a file of a test/ directory must not be published", + "secret.pem: a certificate or a key must not be published", + ], + ); + }); + + it("refuses maps with absolute or outside sources", () => { + deepEqual( + errors({ + "index.ts": "", + "dist/index.js": "", + "dist/index.js.map": map([ + "/Users/dev/binden/index.ts", + "../../index.ts", + "file:///dev/index.ts", + null, + ]), + }), + [ + 'dist/index.js.map: source "/Users/dev/binden/index.ts" is not a relative path inside the package', + 'dist/index.js.map: source "../../index.ts" is not a relative path inside the package', + 'dist/index.js.map: source "file:///dev/index.ts" is not a relative path inside the package', + "dist/index.js.map: source null is not a relative path inside the package", + ], + ); + }); + + it("refuses maps whose sources are not published", () => { + deepEqual( + errors({ + "dist/index.js": "", + "dist/index.js.map": map(["../index.ts", "../test/a.ts"]), + }), + [ + "dist/index.js.map: source index.ts is not published", + "dist/index.js.map: source test/a.ts is not published", + ], + ); + }); + + it("refuses a sourceRoot", () => { + deepEqual( + errors({ + "index.ts": "", + "dist/index.js": "", + "dist/index.js.map": map(["../index.ts"], { + sourceRoot: "/Users/dev/binden/", + }), + }), + [ + 'dist/index.js.map: sourceRoot "/Users/dev/binden/" (the sources must be relative to the map)', + ], + ); + }); + + it("refuses maps without their generated file or without sources", () => { + deepEqual( + errors({ + "index.ts": "", + "dist/index.js.map": map(["../index.ts"]), + "dist/a.js": "", + "dist/a.js.map": map([]), + }), + [ + "dist/index.js.map: its generated file is not published", + "dist/a.js.map: no sources", + ], + ); + }); + + it("refuses invalid maps", () => { + for (const content of [ + "{", + "null", + JSON.stringify({ version: 2, sources: ["../index.ts"] }), + JSON.stringify({ version: 3, sources: "../index.ts" }), + ]) { + deepEqual( + errors({ "index.ts": "", "dist/index.js": "", "a.map": content }), + ["a.map: invalid source map"], + content, + ); + } + }); + + it("refuses links to maps that are not published or not relative", () => { + deepEqual( + errors({ + "dist/a.js": "//# sourceMappingURL=a.js.map\n", + "dist/b.mjs": "//# sourceMappingURL=/Users/dev/dist/b.mjs.map", + "dist/c.d.ts": + "//@ sourceMappingURL=data:application/json;base64,e30=", + "dist/d.cjs": "//# sourceMappingURL=../../d.cjs.map", + "dist/e.d.mts": "//# sourceMappingURL=e.d.mts.js", + "dist/f.js": "//# sourceMappingURL=\n", + }), + [ + "dist/a.js: sourceMappingURL dist/a.js.map is not published", + 'dist/b.mjs: sourceMappingURL "/Users/dev/dist/b.mjs.map" is not a relative path to a map inside the package', + 'dist/c.d.ts: sourceMappingURL "data:application/json;base64,e30=" is not a relative path to a map inside the package', + 'dist/d.cjs: sourceMappingURL "../../d.cjs.map" is not a relative path to a map inside the package', + 'dist/e.d.mts: sourceMappingURL "e.d.mts.js" is not a relative path to a map inside the package', + 'dist/f.js: sourceMappingURL "" is not a relative path to a map inside the package', + ], + ); + }); + + it("only reads the link at the end of a generated file", () => { + deepEqual( + errors({ + "dist/a.js": + 'const a = "//# sourceMappingURL=/abs/a.js.map";\nexport { a };\n', + "README.md": "//# sourceMappingURL=/abs/a.js.map", + }), + [], + ); + }); + }); +}); From ad7af8d058c4530062221b6fb02d813ed17a40a9 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 02:58:58 +0300 Subject: [PATCH 181/675] build: publish the sources and the source maps of the packages A stack trace or "go to definition" in an application stopped at the compiled JavaScript or at the declarations of the packages. Every package now publishes the source maps of its modules and of its declarations, with the TypeScript sources that they point to: the root `index.ts` and `src/`, without the tests (`dist/test` and `test/` are not published). The maps point to the sources with relative paths only, which scripts/pack-rules.mjs checks in every tarball, and scripts/pack-files.json lists the new files. Co-Authored-By: Claude Opus 5.5 --- packages/binden/package.json | 4 +- packages/body/package.json | 3 +- packages/compression/package.json | 4 +- packages/logger/package.json | 3 +- packages/multipart/package.json | 4 +- scripts/pack-files.json | 156 +++++++++++++++++++++++++++++- scripts/packages.test.mjs | 28 ++++++ tsconfig.base.json | 2 + 8 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 scripts/packages.test.mjs diff --git a/packages/binden/package.json b/packages/binden/package.json index 4393d524..f6226bd0 100644 --- a/packages/binden/package.json +++ b/packages/binden/package.json @@ -7,7 +7,9 @@ "types": "dist/index.d.ts", "files": [ "dist", - "!dist/test" + "!dist/test", + "index.ts", + "src" ], "engines": { "node": ">=24.14.1", diff --git a/packages/body/package.json b/packages/body/package.json index 84766ae3..347b0ab6 100644 --- a/packages/body/package.json +++ b/packages/body/package.json @@ -7,7 +7,8 @@ "types": "dist/index.d.ts", "files": [ "dist", - "!dist/test" + "!dist/test", + "index.ts" ], "engines": { "node": ">=24.14.1", diff --git a/packages/compression/package.json b/packages/compression/package.json index 9f646480..713deeeb 100644 --- a/packages/compression/package.json +++ b/packages/compression/package.json @@ -7,7 +7,9 @@ "types": "dist/index.d.ts", "files": [ "dist", - "!dist/test" + "!dist/test", + "index.ts", + "src" ], "engines": { "node": ">=24.14.1", diff --git a/packages/logger/package.json b/packages/logger/package.json index 3c72f537..b505f949 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -7,7 +7,8 @@ "types": "dist/index.d.ts", "files": [ "dist", - "!dist/test" + "!dist/test", + "index.ts" ], "engines": { "node": ">=24.14.1", diff --git a/packages/multipart/package.json b/packages/multipart/package.json index 5de2c719..5d5b7f36 100644 --- a/packages/multipart/package.json +++ b/packages/multipart/package.json @@ -7,7 +7,9 @@ "types": "dist/index.d.ts", "files": [ "dist", - "!dist/test" + "!dist/test", + "index.ts", + "src" ], "engines": { "node": ">=24.14.1", diff --git a/scripts/pack-files.json b/scripts/pack-files.json index 83efb4a4..46308045 100644 --- a/scripts/pack-files.json +++ b/scripts/pack-files.json @@ -3,125 +3,275 @@ "LICENSE", "README.md", "dist/index.d.ts", + "dist/index.d.ts.map", "dist/index.js", + "dist/index.js.map", + "index.ts", "package.json" ], "binden": [ "LICENSE", "README.md", "dist/index.d.ts", + "dist/index.d.ts.map", "dist/index.js", + "dist/index.js.map", "dist/src/binden.d.ts", + "dist/src/binden.d.ts.map", "dist/src/binden.js", + "dist/src/binden.js.map", "dist/src/context.d.ts", + "dist/src/context.d.ts.map", "dist/src/context.js", + "dist/src/context.js.map", "dist/src/delay.d.ts", + "dist/src/delay.d.ts.map", "dist/src/delay.js", + "dist/src/delay.js.map", "dist/src/error.d.ts", + "dist/src/error.d.ts.map", "dist/src/error.js", + "dist/src/error.js.map", "dist/src/exchange.d.ts", + "dist/src/exchange.d.ts.map", "dist/src/exchange.js", + "dist/src/exchange.js.map", "dist/src/headers/accept-charset.d.ts", + "dist/src/headers/accept-charset.d.ts.map", "dist/src/headers/accept-charset.js", + "dist/src/headers/accept-charset.js.map", "dist/src/headers/accept-encoding.d.ts", + "dist/src/headers/accept-encoding.d.ts.map", "dist/src/headers/accept-encoding.js", + "dist/src/headers/accept-encoding.js.map", "dist/src/headers/accept-language.d.ts", + "dist/src/headers/accept-language.d.ts.map", "dist/src/headers/accept-language.js", + "dist/src/headers/accept-language.js.map", "dist/src/headers/accept.d.ts", + "dist/src/headers/accept.d.ts.map", "dist/src/headers/accept.js", + "dist/src/headers/accept.js.map", "dist/src/headers/authorization.d.ts", + "dist/src/headers/authorization.d.ts.map", "dist/src/headers/authorization.js", + "dist/src/headers/authorization.js.map", "dist/src/headers/content-encoding.d.ts", + "dist/src/headers/content-encoding.d.ts.map", "dist/src/headers/content-encoding.js", + "dist/src/headers/content-encoding.js.map", "dist/src/headers/content-range.d.ts", + "dist/src/headers/content-range.d.ts.map", "dist/src/headers/content-range.js", + "dist/src/headers/content-range.js.map", "dist/src/headers/content-type.d.ts", + "dist/src/headers/content-type.d.ts.map", "dist/src/headers/content-type.js", + "dist/src/headers/content-type.js.map", "dist/src/headers/cookie.d.ts", + "dist/src/headers/cookie.d.ts.map", "dist/src/headers/cookie.js", + "dist/src/headers/cookie.js.map", "dist/src/headers/forwarded.d.ts", + "dist/src/headers/forwarded.d.ts.map", "dist/src/headers/forwarded.js", + "dist/src/headers/forwarded.js.map", "dist/src/headers/http-date.d.ts", + "dist/src/headers/http-date.d.ts.map", "dist/src/headers/http-date.js", + "dist/src/headers/http-date.js.map", "dist/src/headers/if-modified-since.d.ts", + "dist/src/headers/if-modified-since.d.ts.map", "dist/src/headers/if-modified-since.js", + "dist/src/headers/if-modified-since.js.map", "dist/src/headers/list.d.ts", + "dist/src/headers/list.d.ts.map", "dist/src/headers/list.js", + "dist/src/headers/list.js.map", "dist/src/headers/range.d.ts", + "dist/src/headers/range.d.ts.map", "dist/src/headers/range.js", + "dist/src/headers/range.js.map", "dist/src/logger.d.ts", + "dist/src/logger.d.ts.map", "dist/src/logger.js", + "dist/src/logger.js.map", "dist/src/middleware.d.ts", + "dist/src/middleware.d.ts.map", "dist/src/middleware.js", + "dist/src/middleware.js.map", "dist/src/mime.d.ts", + "dist/src/mime.d.ts.map", "dist/src/mime.js", + "dist/src/mime.js.map", "dist/src/property.d.ts", + "dist/src/property.d.ts.map", "dist/src/property.js", + "dist/src/property.js.map", "dist/src/proxy.d.ts", + "dist/src/proxy.d.ts.map", "dist/src/proxy.js", + "dist/src/proxy.js.map", "dist/src/redirect.d.ts", + "dist/src/redirect.d.ts.map", "dist/src/redirect.js", + "dist/src/redirect.js.map", "dist/src/request.d.ts", + "dist/src/request.d.ts.map", "dist/src/request.js", + "dist/src/request.js.map", "dist/src/response.d.ts", + "dist/src/response.d.ts.map", "dist/src/response.js", + "dist/src/response.js.map", "dist/src/route.d.ts", + "dist/src/route.d.ts.map", "dist/src/route.js", + "dist/src/route.js.map", "dist/src/router.d.ts", + "dist/src/router.d.ts.map", "dist/src/router.js", + "dist/src/router.js.map", "dist/src/seal.d.ts", + "dist/src/seal.d.ts.map", "dist/src/seal.js", + "dist/src/seal.js.map", "dist/src/send-file.d.ts", + "dist/src/send-file.d.ts.map", "dist/src/send-file.js", + "dist/src/send-file.js.map", "dist/src/shutdown.d.ts", + "dist/src/shutdown.d.ts.map", "dist/src/shutdown.js", + "dist/src/shutdown.js.map", "dist/src/stack.d.ts", + "dist/src/stack.d.ts.map", "dist/src/stack.js", + "dist/src/stack.js.map", "dist/src/status.d.ts", + "dist/src/status.d.ts.map", "dist/src/status.js", - "package.json" + "dist/src/status.js.map", + "index.ts", + "package.json", + "src/binden.ts", + "src/context.ts", + "src/delay.ts", + "src/error.ts", + "src/exchange.ts", + "src/headers/accept-charset.ts", + "src/headers/accept-encoding.ts", + "src/headers/accept-language.ts", + "src/headers/accept.ts", + "src/headers/authorization.ts", + "src/headers/content-encoding.ts", + "src/headers/content-range.ts", + "src/headers/content-type.ts", + "src/headers/cookie.ts", + "src/headers/forwarded.ts", + "src/headers/http-date.ts", + "src/headers/if-modified-since.ts", + "src/headers/list.ts", + "src/headers/range.ts", + "src/logger.ts", + "src/middleware.ts", + "src/mime.ts", + "src/property.ts", + "src/proxy.ts", + "src/redirect.ts", + "src/request.ts", + "src/response.ts", + "src/route.ts", + "src/router.ts", + "src/seal.ts", + "src/send-file.ts", + "src/shutdown.ts", + "src/stack.ts", + "src/status.ts" ], "@binden/body": [ "LICENSE", "README.md", "dist/index.d.ts", + "dist/index.d.ts.map", "dist/index.js", + "dist/index.js.map", + "index.ts", "package.json" ], "@binden/compression": [ "LICENSE", "README.md", "dist/index.d.ts", + "dist/index.d.ts.map", "dist/index.js", + "dist/index.js.map", "dist/src/codings.d.ts", + "dist/src/codings.d.ts.map", "dist/src/codings.js", + "dist/src/codings.js.map", "dist/src/filter.d.ts", + "dist/src/filter.d.ts.map", "dist/src/filter.js", + "dist/src/filter.js.map", "dist/src/negotiate.d.ts", + "dist/src/negotiate.d.ts.map", "dist/src/negotiate.js", + "dist/src/negotiate.js.map", "dist/src/response.d.ts", + "dist/src/response.d.ts.map", "dist/src/response.js", - "package.json" + "dist/src/response.js.map", + "index.ts", + "package.json", + "src/codings.ts", + "src/filter.ts", + "src/negotiate.ts", + "src/response.ts" ], "@binden/multipart": [ "LICENSE", "README.md", "dist/index.d.ts", + "dist/index.d.ts.map", "dist/index.js", + "dist/index.js.map", "dist/src/disk.d.ts", + "dist/src/disk.d.ts.map", "dist/src/disk.js", + "dist/src/disk.js.map", "dist/src/file.d.ts", + "dist/src/file.d.ts.map", "dist/src/file.js", + "dist/src/file.js.map", "dist/src/filename.d.ts", + "dist/src/filename.d.ts.map", "dist/src/filename.js", + "dist/src/filename.js.map", "dist/src/info.d.ts", + "dist/src/info.d.ts.map", "dist/src/info.js", + "dist/src/info.js.map", "dist/src/multipart.d.ts", + "dist/src/multipart.d.ts.map", "dist/src/multipart.js", + "dist/src/multipart.js.map", "dist/src/options.d.ts", + "dist/src/options.d.ts.map", "dist/src/options.js", + "dist/src/options.js.map", "dist/src/race.d.ts", + "dist/src/race.d.ts.map", "dist/src/race.js", - "package.json" + "dist/src/race.js.map", + "index.ts", + "package.json", + "src/disk.ts", + "src/file.ts", + "src/filename.ts", + "src/info.ts", + "src/multipart.ts", + "src/options.ts", + "src/race.ts" ] } diff --git a/scripts/packages.test.mjs b/scripts/packages.test.mjs new file mode 100644 index 00000000..824cb6d9 --- /dev/null +++ b/scripts/packages.test.mjs @@ -0,0 +1,28 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { describe, it } from "node:test"; +import { join } from "node:path"; +import { readFileSync } from "node:fs"; + +// The manifests that the workspaces publish +const root = join(import.meta.dirname, ".."); +const readJSON = (path) => JSON.parse(readFileSync(join(root, path), "utf8")); +const { workspaces } = readJSON("package.json"); +const packages = workspaces.map((dir) => ({ + dir, + manifest: readJSON(`${dir}/package.json`), +})); + +describe("the manifests of the workspaces", () => { + it("publish their sources and their maps, but not their tests", () => { + const { compilerOptions } = readJSON("tsconfig.base.json"); + equal(compilerOptions.sourceMap, true); + equal(compilerOptions.declarationMap, true); + for (const { dir, manifest } of packages) { + deepEqual( + manifest.files.slice(0, 3), + ["dist", "!dist/test", "index.ts"], + dir, + ); + } + }); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index c4af72e3..67657da6 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -7,6 +7,8 @@ "moduleResolution": "nodenext", "resolveJsonModule": true, "declaration": true, + "declarationMap": true, + "sourceMap": true, "newLine": "lf", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, From a7479528843831181b4b1f0b580f07aa3516cfb7 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 02:59:40 +0300 Subject: [PATCH 182/675] build: ship MIGRATION-4.md and PRODUCTION.md in the tarball of binden The guides of the root are what the users of binden 4 need first (the migration from 3 and the production settings), and npm shows only what a package contains. The `prepack` of binden copies them from the root after its build (scripts/copy-documents.mjs), and `files` publishes them. The copies are ignored by git and removed by the `prebuild` (scripts/clean.mjs). A missing document fails the copy, so `npm pack` and `scripts/release.mjs pack` stop before a release can be packed without it. `pack-check` runs the same copy before `npm pack --dry-run`, whose `prepack` does not run. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 5 ++ packages/binden/package.json | 9 ++- scripts/clean.mjs | 21 ++++- scripts/copy-documents.mjs | 38 +++++++++ scripts/copy-documents.test.mjs | 138 ++++++++++++++++++++++++++++++++ scripts/pack-check.mjs | 8 +- scripts/pack-check.test.mjs | 41 +++++++++- scripts/pack-files.json | 2 + 8 files changed, 252 insertions(+), 10 deletions(-) create mode 100644 scripts/copy-documents.mjs create mode 100644 scripts/copy-documents.test.mjs diff --git a/.gitignore b/.gitignore index 59783b49..e577d705 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,8 @@ docs/ .env .env.* !.env.example + +# Documents of the root that `prepack` copies into the binden package +# (scripts/copy-documents.mjs) +packages/binden/MIGRATION-4.md +packages/binden/PRODUCTION.md diff --git a/packages/binden/package.json b/packages/binden/package.json index f6226bd0..f6be739a 100644 --- a/packages/binden/package.json +++ b/packages/binden/package.json @@ -9,7 +9,9 @@ "dist", "!dist/test", "index.ts", - "src" + "src", + "MIGRATION-4.md", + "PRODUCTION.md" ], "engines": { "node": ">=24.14.1", @@ -17,8 +19,9 @@ }, "scripts": { "build": "tsc", - "prebuild": "node ../../scripts/clean.mjs", - "prepack": "npm run build", + "documents": "node ../../scripts/copy-documents.mjs MIGRATION-4.md PRODUCTION.md", + "prebuild": "node ../../scripts/clean.mjs MIGRATION-4.md PRODUCTION.md", + "prepack": "npm run build && npm run documents", "test": "node ../../scripts/test.mjs" }, "repository": { diff --git a/scripts/clean.mjs b/scripts/clean.mjs index df8a3baa..cdd719d0 100644 --- a/scripts/clean.mjs +++ b/scripts/clean.mjs @@ -1,6 +1,21 @@ #!/usr/bin/env node -// Removes the `dist` directory of the current workspace (its `prebuild` script), -// without a shell command (`rm -rf` does not exist on Windows) +// Removes the `dist` directory of the current workspace (its `prebuild` script), and +// the files given as arguments (the documents that `scripts/copy-documents.mjs` copies +// into it), without a shell command (`rm -rf` does not exist on Windows) +// +// node ../../scripts/clean.mjs [...] +import { basename } from "node:path"; import { rmSync } from "node:fs"; -rmSync("dist", { force: true, recursive: true }); +const files = process.argv.slice(2); + +// Only names in the workspace: nothing outside of it is removed +for (const file of files) { + if (basename(file) !== file || file === "." || file === "..") { + throw new Error(`Not a file name of the workspace: ${file}`); + } +} + +for (const path of ["dist", ...files]) { + rmSync(path, { force: true, recursive: true }); +} diff --git a/scripts/copy-documents.mjs b/scripts/copy-documents.mjs new file mode 100644 index 00000000..1156dc22 --- /dev/null +++ b/scripts/copy-documents.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +// Copies documents of the root of the repository into the current workspace (its +// `documents` script, which its `prepack` runs after the build), so that its tarball +// ships them: `binden` ships MIGRATION-4.md and PRODUCTION.md. The copies are ignored +// by git and removed by `scripts/clean.mjs`. A missing document fails `npm pack` (and +// `scripts/release.mjs pack`), so a release cannot be packed without it. +// +// node ../../scripts/copy-documents.mjs ... +import { basename, join } from "node:path"; +import { copyFileSync, statSync } from "node:fs"; + +const root = join(import.meta.dirname, ".."); +const files = process.argv.slice(2); + +if (files.length === 0) { + throw new Error("Usage: scripts/copy-documents.mjs ..."); +} +for (const file of files) { + if (basename(file) !== file || file === "." || file === "..") { + throw new Error(`Not a file name of the root of the repository: ${file}`); + } +} + +// Every document is checked before the first one is copied +const missing = files.filter( + (file) => + !(statSync(join(root, file), { throwIfNoEntry: false })?.isFile() ?? false), +); +if (missing.length > 0) { + throw new Error( + `Missing at the root of the repository, but published by this package: ${missing.join(", ")}`, + ); +} + +for (const file of files) { + copyFileSync(join(root, file), file); + process.stderr.write(`[documents] ${file}\n`); +} diff --git a/scripts/copy-documents.test.mjs b/scripts/copy-documents.test.mjs new file mode 100644 index 00000000..f60508ef --- /dev/null +++ b/scripts/copy-documents.test.mjs @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, it } from "node:test"; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { equal, match } from "node:assert/strict"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; + +const scripts = ["copy-documents.mjs", "clean.mjs"]; + +describe("scripts/copy-documents.mjs and scripts/clean.mjs", () => { + let root = ""; + let workspace = ""; + + // Runs a script in the workspace, as its npm scripts do + const run = (script, ...args) => + spawnSync(process.execPath, [join(root, "scripts", script), ...args], { + cwd: workspace, + encoding: "utf8", + timeout: 60_000, + }); + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "binden-documents-")); + workspace = join(root, "packages", "a"); + mkdirSync(join(root, "scripts")); + mkdirSync(workspace, { recursive: true }); + for (const script of scripts) { + copyFileSync( + join(import.meta.dirname, script), + join(root, "scripts", script), + ); + } + writeFileSync(join(root, "MIGRATION.md"), "# Migration\n"); + writeFileSync(join(root, "PRODUCTION.md"), "# Production\n"); + }); + + afterEach(() => { + rmSync(root, { force: true, recursive: true }); + }); + + describe("copy-documents", () => { + it("copies the documents of the root into the workspace", () => { + writeFileSync(join(workspace, "PRODUCTION.md"), "stale\n"); + const { status, stderr } = run( + "copy-documents.mjs", + "MIGRATION.md", + "PRODUCTION.md", + ); + equal(status, 0, stderr); + equal( + readFileSync(join(workspace, "MIGRATION.md"), "utf8"), + "# Migration\n", + ); + equal( + readFileSync(join(workspace, "PRODUCTION.md"), "utf8"), + "# Production\n", + ); + match( + stderr, + /\[documents\] MIGRATION\.md\n\[documents\] PRODUCTION\.md/u, + ); + }); + + it("fails without copying anything when a document is missing", () => { + rmSync(join(root, "PRODUCTION.md")); + mkdirSync(join(root, "SECURITY.md")); + const { status, stderr } = run( + "copy-documents.mjs", + "MIGRATION.md", + "PRODUCTION.md", + "SECURITY.md", + ); + equal(status, 1); + match( + stderr, + /Missing at the root of the repository, but published by this package: PRODUCTION\.md, SECURITY\.md/u, + ); + equal(existsSync(join(workspace, "MIGRATION.md")), false); + }); + + it("refuses paths that are not file names of the root", () => { + for (const file of ["../MIGRATION.md", "docs/MIGRATION.md", ".", ".."]) { + const { status, stderr } = run("copy-documents.mjs", file); + equal(status, 1, file); + match(stderr, /Not a file name of the root of the repository/u); + } + }); + + it("fails without documents", () => { + const { status, stderr } = run("copy-documents.mjs"); + equal(status, 1); + match(stderr, /Usage: scripts\/copy-documents\.mjs \.\.\./u); + }); + }); + + describe("clean", () => { + it("removes dist and the given files of the workspace", () => { + mkdirSync(join(workspace, "dist", "src"), { recursive: true }); + writeFileSync(join(workspace, "dist", "src", "a.js"), ""); + for (const file of ["MIGRATION.md", "PRODUCTION.md", "README.md"]) { + writeFileSync(join(workspace, file), ""); + } + const { status, stderr } = run( + "clean.mjs", + "MIGRATION.md", + "PRODUCTION.md", + ); + equal(status, 0, stderr); + equal(existsSync(join(workspace, "dist")), false); + equal(existsSync(join(workspace, "MIGRATION.md")), false); + equal(existsSync(join(workspace, "PRODUCTION.md")), false); + equal(existsSync(join(workspace, "README.md")), true); + equal(existsSync(join(root, "MIGRATION.md")), true); + }); + + it("passes when there is nothing to remove", () => { + const { status, stderr } = run("clean.mjs", "MIGRATION.md"); + equal(status, 0, stderr); + }); + + it("refuses paths outside of the workspace", () => { + for (const file of ["../../MIGRATION.md", "..", ".", "a/b"]) { + const { status, stderr } = run("clean.mjs", file); + equal(status, 1, file); + match(stderr, /Not a file name of the workspace/u); + } + equal(existsSync(join(root, "MIGRATION.md")), true); + }); + }); +}); diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs index 558f0dff..58cb03d6 100644 --- a/scripts/pack-check.mjs +++ b/scripts/pack-check.mjs @@ -8,7 +8,9 @@ // // node scripts/pack-check.mjs `npm pack --dry-run` of the workspaces, // which must be built before (their -// `prepack` does not run) +// `prepack` does not run, only their +// `documents` script, which copies the +// documents of the root that they ship) // node scripts/pack-check.mjs --tarballs the archives that `scripts/release.mjs // pack ` prepared, without packing // again (their integrity is checked) @@ -40,6 +42,10 @@ function workspaceDirs() { // The packed files of each package, and how to read them: { name: { paths, read } } function packedFiles() { + npm(["run", "documents", "--workspaces", "--if-present"], { + cwd: root, + stdio: ["ignore", process.stderr, "inherit"], + }); const packed = JSON.parse( npm(["pack", "--dry-run", "--json", "--workspaces", "--ignore-scripts"], { cwd: root, diff --git a/scripts/pack-check.test.mjs b/scripts/pack-check.test.mjs index f7e48a12..f06e6def 100644 --- a/scripts/pack-check.test.mjs +++ b/scripts/pack-check.test.mjs @@ -15,9 +15,13 @@ import { npmTarball } from "./fixtures/tar.mjs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; -// A fake npm CLI (run as `npm_execpath`) whose `npm pack --json` prints FAKE_NPM_PACK, -// and that fails for anything else -const fakeNpm = `if (process.argv[2] !== "pack") process.exit(1); +// A fake npm CLI (run as `npm_execpath`) that logs its arguments to FAKE_NPM_LOG, whose +// `npm pack --json` prints FAKE_NPM_PACK, whose `npm run` does nothing, and that fails +// for anything else +const fakeNpm = `const { appendFileSync } = require("node:fs"); +appendFileSync(process.env.FAKE_NPM_LOG, JSON.stringify(process.argv.slice(2)) + "\\n"); +if (process.argv[2] === "run") process.exit(0); +if (process.argv[2] !== "pack") process.exit(1); process.stdout.write(process.env.FAKE_NPM_PACK);\n`; // A source map of `sources`, with a relative `sourceRoot` as `tsc` writes it @@ -60,10 +64,18 @@ describe("scripts/pack-check.mjs", () => { ...process.env, npm_execpath: join(root, "npm.cjs"), FAKE_NPM_PACK: packed(files), + FAKE_NPM_LOG: join(root, "npm.log"), }, }, ); + // The commands that the fake npm ran + const npmCalls = () => + readFileSync(join(root, "npm.log"), "utf8") + .split("\n") + .filter((line) => line !== "") + .map((line) => JSON.parse(line)); + // A workspace `packages/` of the root with `files` ({ path: content }) const workspace = (name, files) => { writeFileSync( @@ -141,6 +153,29 @@ describe("scripts/pack-check.mjs", () => { match(stderr, /1 workspaces with the expected files/u); }); + it("copies the documents of the workspaces before packing them", () => { + const { status } = check({ + a: ["dist/index.js", "package.json", "LICENSE"], + }); + equal(status, 0); + deepEqual(npmCalls(), [ + ["run", "documents", "--workspaces", "--if-present"], + ["pack", "--dry-run", "--json", "--workspaces", "--ignore-scripts"], + ]); + }); + + it("fails when the documents cannot be copied", () => { + writeFileSync( + join(root, "npm.cjs"), + `process.exit(process.argv[2] === "run" ? 1 : 0);\n`, + ); + const { status, stderr } = check({ + a: ["dist/index.js", "package.json", "LICENSE"], + }); + equal(status, 1); + match(stderr, /Command failed: .*run documents --workspaces --if-present/u); + }); + it("refuses tests, certificates and secrets even when they are expected", () => { const files = { a: ["dist/test/a.test.js", "key.pem", ".npmrc", "README.md"], diff --git a/scripts/pack-files.json b/scripts/pack-files.json index 46308045..b5a18b1f 100644 --- a/scripts/pack-files.json +++ b/scripts/pack-files.json @@ -11,6 +11,8 @@ ], "binden": [ "LICENSE", + "MIGRATION-4.md", + "PRODUCTION.md", "README.md", "dist/index.d.ts", "dist/index.d.ts.map", From 74f0c67fba59cf35e775d908468d166cc64aab8c Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 03:00:37 +0300 Subject: [PATCH 183/675] feat!: close the deep imports with an exports map in the packages Every module of a package could be imported by its path (`binden/dist/src/request.js`), so each internal module was a public API that could not change without a major release, and a deep import could load a second copy of a module next to the one of the root. The packages have an `exports` map: the root (with its declarations first, for TypeScript) and `package.json`. `main` and `types` stay for the tools that do not read `exports`. The packages already import each other from their roots only. BREAKING CHANGE: a package can be imported from its root only (and its package.json); import from `binden`, `@binden/body`, `@binden/compression`, `@binden/logger` or `@binden/multipart` instead of a path inside `dist/`. Co-Authored-By: Claude Opus 5.5 --- packages/binden/package.json | 7 +++++ packages/body/package.json | 7 +++++ packages/compression/package.json | 7 +++++ packages/logger/package.json | 7 +++++ packages/multipart/package.json | 7 +++++ scripts/packages.test.mjs | 50 +++++++++++++++++++++++++++++-- 6 files changed, 83 insertions(+), 2 deletions(-) diff --git a/packages/binden/package.json b/packages/binden/package.json index f6be739a..55eb597a 100644 --- a/packages/binden/package.json +++ b/packages/binden/package.json @@ -5,6 +5,13 @@ "main": "dist/index.js", "type": "module", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, "files": [ "dist", "!dist/test", diff --git a/packages/body/package.json b/packages/body/package.json index 347b0ab6..27928fda 100644 --- a/packages/body/package.json +++ b/packages/body/package.json @@ -5,6 +5,13 @@ "main": "dist/index.js", "type": "module", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, "files": [ "dist", "!dist/test", diff --git a/packages/compression/package.json b/packages/compression/package.json index 713deeeb..828c81d5 100644 --- a/packages/compression/package.json +++ b/packages/compression/package.json @@ -5,6 +5,13 @@ "main": "dist/index.js", "type": "module", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, "files": [ "dist", "!dist/test", diff --git a/packages/logger/package.json b/packages/logger/package.json index b505f949..e07b5806 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -5,6 +5,13 @@ "main": "dist/index.js", "type": "module", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, "files": [ "dist", "!dist/test", diff --git a/packages/multipart/package.json b/packages/multipart/package.json index 5d5b7f36..a139b640 100644 --- a/packages/multipart/package.json +++ b/packages/multipart/package.json @@ -5,6 +5,13 @@ "main": "dist/index.js", "type": "module", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, "files": [ "dist", "!dist/test", diff --git a/scripts/packages.test.mjs b/scripts/packages.test.mjs index 824cb6d9..56b7c4be 100644 --- a/scripts/packages.test.mjs +++ b/scripts/packages.test.mjs @@ -1,9 +1,11 @@ -import { deepEqual, equal } from "node:assert/strict"; +import { deepEqual, equal, throws } from "node:assert/strict"; import { describe, it } from "node:test"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { readFileSync } from "node:fs"; -// The manifests that the workspaces publish +// The manifests that the workspaces publish, and how Node.js resolves the workspaces +// (linked into node_modules) through them, as it resolves the installed packages const root = join(import.meta.dirname, ".."); const readJSON = (path) => JSON.parse(readFileSync(join(root, path), "utf8")); const { workspaces } = readJSON("package.json"); @@ -13,6 +15,50 @@ const packages = workspaces.map((dir) => ({ })); describe("the manifests of the workspaces", () => { + it("export the root and package.json only", () => { + for (const { dir, manifest } of packages) { + deepEqual( + manifest.exports, + { + ".": { types: "./dist/index.d.ts", default: "./dist/index.js" }, + "./package.json": "./package.json", + }, + dir, + ); + // For the tools that do not read `exports` + equal(manifest.main, "dist/index.js", dir); + equal(manifest.types, "dist/index.d.ts", dir); + } + }); + + it("resolve the root and package.json, and no other module", () => { + for (const { dir, manifest } of packages) { + const { name } = manifest; + equal( + import.meta.resolve(name), + pathToFileURL(join(root, dir, "dist/index.js")).href, + name, + ); + equal( + import.meta.resolve(`${name}/package.json`), + pathToFileURL(join(root, dir, "package.json")).href, + name, + ); + for (const path of [ + "dist/index.js", + "dist/src/a.js", + "index.ts", + "src", + ]) { + throws( + () => import.meta.resolve(`${name}/${path}`), + { code: "ERR_PACKAGE_PATH_NOT_EXPORTED" }, + `${name}/${path}`, + ); + } + } + }); + it("publish their sources and their maps, but not their tests", () => { const { compilerOptions } = readJSON("tsconfig.base.json"); equal(compilerOptions.sourceMap, true); From 7fcf9aa19fc1b1fdd8ccff5c36d6e39a1fd99e57 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 03:00:49 +0300 Subject: [PATCH 184/675] build: mark the packages as free of side effects A bundler keeps every module of a package that an application imports, even the ones whose exports it does not use, unless the package declares that importing its modules does nothing else. No module of the packages has an effect when it is imported: they declare classes, functions and constants, the default instances are plain objects, and the default logger of @binden/logger creates its `pino` logger on first use. `sideEffects: false` lets the bundlers drop the unused modules. Co-Authored-By: Claude Opus 5.5 --- packages/binden/package.json | 1 + packages/body/package.json | 1 + packages/compression/package.json | 1 + packages/logger/package.json | 1 + packages/multipart/package.json | 1 + scripts/packages.test.mjs | 6 ++++++ 6 files changed, 11 insertions(+) diff --git a/packages/binden/package.json b/packages/binden/package.json index 55eb597a..643d2b27 100644 --- a/packages/binden/package.json +++ b/packages/binden/package.json @@ -12,6 +12,7 @@ }, "./package.json": "./package.json" }, + "sideEffects": false, "files": [ "dist", "!dist/test", diff --git a/packages/body/package.json b/packages/body/package.json index 27928fda..1b75c406 100644 --- a/packages/body/package.json +++ b/packages/body/package.json @@ -12,6 +12,7 @@ }, "./package.json": "./package.json" }, + "sideEffects": false, "files": [ "dist", "!dist/test", diff --git a/packages/compression/package.json b/packages/compression/package.json index 828c81d5..17a56c5b 100644 --- a/packages/compression/package.json +++ b/packages/compression/package.json @@ -12,6 +12,7 @@ }, "./package.json": "./package.json" }, + "sideEffects": false, "files": [ "dist", "!dist/test", diff --git a/packages/logger/package.json b/packages/logger/package.json index e07b5806..44c84a77 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -12,6 +12,7 @@ }, "./package.json": "./package.json" }, + "sideEffects": false, "files": [ "dist", "!dist/test", diff --git a/packages/multipart/package.json b/packages/multipart/package.json index a139b640..31bbf43d 100644 --- a/packages/multipart/package.json +++ b/packages/multipart/package.json @@ -12,6 +12,7 @@ }, "./package.json": "./package.json" }, + "sideEffects": false, "files": [ "dist", "!dist/test", diff --git a/scripts/packages.test.mjs b/scripts/packages.test.mjs index 56b7c4be..e8825255 100644 --- a/scripts/packages.test.mjs +++ b/scripts/packages.test.mjs @@ -59,6 +59,12 @@ describe("the manifests of the workspaces", () => { } }); + it("have no side effects", () => { + for (const { dir, manifest } of packages) { + equal(manifest.sideEffects, false, dir); + } + }); + it("publish their sources and their maps, but not their tests", () => { const { compilerOptions } = readJSON("tsconfig.base.json"); equal(compilerOptions.sourceMap, true); From 084bf802c4d5e73153e5980646a4068bf528981a Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 03:01:37 +0300 Subject: [PATCH 185/675] build: require Node.js only in the engines of the packages The packages required npm 11.11 or later, but npm is a tool of this repository (its lockfile and its workspaces), not a requirement of the packages: an application may install them with another package manager or an older npm, and `engine-strict` refused them then. The packages require Node.js 24.14.1 or later only. The root of the repository keeps npm in its `engines`. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 15 +++++---------- packages/binden/package.json | 3 +-- packages/body/package.json | 3 +-- packages/compression/package.json | 3 +-- packages/logger/package.json | 3 +-- packages/multipart/package.json | 3 +-- scripts/packages.test.mjs | 9 ++++++++- 7 files changed, 18 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4e4a2853..b148135c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9125,8 +9125,7 @@ "sinon": "^22.1.0" }, "engines": { - "node": ">=24.14.1", - "npm": ">=11.11.0" + "node": ">=24.14.1" }, "funding": { "type": "Coinbase Commerce", @@ -9143,8 +9142,7 @@ "undici": "^8.11.2" }, "engines": { - "node": ">=24.14.1", - "npm": ">=11.11.0" + "node": ">=24.14.1" }, "funding": { "type": "Coinbase Commerce", @@ -9172,8 +9170,7 @@ "undici": "^8.3.0" }, "engines": { - "node": ">=24.14.1", - "npm": ">=11.11.0" + "node": ">=24.14.1" }, "funding": { "type": "Coinbase Commerce", @@ -9201,8 +9198,7 @@ "pino": "^10.4.0" }, "engines": { - "node": ">=24.14.1", - "npm": ">=11.11.0" + "node": ">=24.14.1" }, "funding": { "type": "Coinbase Commerce", @@ -9221,8 +9217,7 @@ "undici": "^8.3.0" }, "engines": { - "node": ">=24.14.1", - "npm": ">=11.11.0" + "node": ">=24.14.1" }, "funding": { "type": "Coinbase Commerce", diff --git a/packages/binden/package.json b/packages/binden/package.json index 643d2b27..a3e1b6c3 100644 --- a/packages/binden/package.json +++ b/packages/binden/package.json @@ -22,8 +22,7 @@ "PRODUCTION.md" ], "engines": { - "node": ">=24.14.1", - "npm": ">=11.11.0" + "node": ">=24.14.1" }, "scripts": { "build": "tsc", diff --git a/packages/body/package.json b/packages/body/package.json index 1b75c406..7b1d4da6 100644 --- a/packages/body/package.json +++ b/packages/body/package.json @@ -19,8 +19,7 @@ "index.ts" ], "engines": { - "node": ">=24.14.1", - "npm": ">=11.11.0" + "node": ">=24.14.1" }, "scripts": { "build": "tsc", diff --git a/packages/compression/package.json b/packages/compression/package.json index 17a56c5b..dd92ff4e 100644 --- a/packages/compression/package.json +++ b/packages/compression/package.json @@ -20,8 +20,7 @@ "src" ], "engines": { - "node": ">=24.14.1", - "npm": ">=11.11.0" + "node": ">=24.14.1" }, "scripts": { "build": "tsc", diff --git a/packages/logger/package.json b/packages/logger/package.json index 44c84a77..a6885b48 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -19,8 +19,7 @@ "index.ts" ], "engines": { - "node": ">=24.14.1", - "npm": ">=11.11.0" + "node": ">=24.14.1" }, "scripts": { "build": "tsc", diff --git a/packages/multipart/package.json b/packages/multipart/package.json index 31bbf43d..ad94c0ae 100644 --- a/packages/multipart/package.json +++ b/packages/multipart/package.json @@ -20,8 +20,7 @@ "src" ], "engines": { - "node": ">=24.14.1", - "npm": ">=11.11.0" + "node": ">=24.14.1" }, "scripts": { "build": "tsc", diff --git a/scripts/packages.test.mjs b/scripts/packages.test.mjs index e8825255..e4c61dec 100644 --- a/scripts/packages.test.mjs +++ b/scripts/packages.test.mjs @@ -8,7 +8,7 @@ import { readFileSync } from "node:fs"; // (linked into node_modules) through them, as it resolves the installed packages const root = join(import.meta.dirname, ".."); const readJSON = (path) => JSON.parse(readFileSync(join(root, path), "utf8")); -const { workspaces } = readJSON("package.json"); +const { engines, workspaces } = readJSON("package.json"); const packages = workspaces.map((dir) => ({ dir, manifest: readJSON(`${dir}/package.json`), @@ -65,6 +65,13 @@ describe("the manifests of the workspaces", () => { } }); + it("require Node.js only (npm is a requirement of this repository)", () => { + equal(typeof engines.npm, "string"); + for (const { dir, manifest } of packages) { + deepEqual(manifest.engines, { node: engines.node }, dir); + } + }); + it("publish their sources and their maps, but not their tests", () => { const { compilerOptions } = readJSON("tsconfig.base.json"); equal(compilerOptions.sourceMap, true); From 66f0751d5d9a68ff46833f92e5c2050b656f9f96 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 03:01:48 +0300 Subject: [PATCH 186/675] metadata: describe the packages with more keywords The npm search found the packages by one to four keywords each (@binden/logger by `logger` only). They now name what each package does and what it is built on: the routing and the middlewares of binden, the media types and codings that @binden/body parses, the codings of @binden/compression, the uploads of @binden/multipart and the `pino` logging of @binden/logger. Co-Authored-By: Claude Opus 5.5 --- packages/binden/package.json | 8 +++++++- packages/body/package.json | 10 +++++++++- packages/compression/package.json | 4 ++++ packages/logger/package.json | 7 ++++++- packages/multipart/package.json | 5 +++++ 5 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/binden/package.json b/packages/binden/package.json index a3e1b6c3..43ecec0c 100644 --- a/packages/binden/package.json +++ b/packages/binden/package.json @@ -40,7 +40,13 @@ "HTTP", "REST", "web", - "framework" + "framework", + "server", + "router", + "routing", + "middleware", + "api", + "typescript" ], "author": "Sergey Bakulin ", "license": "MIT", diff --git a/packages/body/package.json b/packages/body/package.json index 7b1d4da6..4ab3087c 100644 --- a/packages/body/package.json +++ b/packages/body/package.json @@ -35,7 +35,15 @@ "keywords": [ "HTTP", "binden", - "body" + "body", + "body-parser", + "middleware", + "json", + "urlencoded", + "form", + "text", + "raw", + "decompression" ], "author": "Sergey Bakulin ", "license": "MIT", diff --git a/packages/compression/package.json b/packages/compression/package.json index dd92ff4e..d11b2b46 100644 --- a/packages/compression/package.json +++ b/packages/compression/package.json @@ -37,9 +37,13 @@ "compress", "compression", "br", + "brotli", "gzip", "deflate", "zstd", + "content-encoding", + "middleware", + "HTTP", "binden" ], "author": "Sergey Bakulin ", diff --git a/packages/logger/package.json b/packages/logger/package.json index a6885b48..269548f1 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -33,7 +33,12 @@ "directory": "packages/logger" }, "keywords": [ - "logger" + "binden", + "logger", + "logging", + "pino", + "json", + "structured-logging" ], "author": "Sergey Bakulin ", "license": "MIT", diff --git a/packages/multipart/package.json b/packages/multipart/package.json index ad94c0ae..335c5daa 100644 --- a/packages/multipart/package.json +++ b/packages/multipart/package.json @@ -36,6 +36,11 @@ "keywords": [ "multipart", "formdata", + "form-data", + "upload", + "file-upload", + "middleware", + "HTTP", "binden", "busboy" ], From b5620d12c69ecff94e5cbe3612b9b8fb526099f6 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 03:04:31 +0300 Subject: [PATCH 187/675] test: compare the resolved workspaces by their real paths in the tooling The test of the `exports` maps compared the URL that Node.js resolves with the URL of the path of the workspace. On Windows, npm links the workspaces with junctions, and the two spellings of the same file could differ (drive letter, short names): the tooling tests run there too. Both sides are now compared as real paths. Co-Authored-By: Claude Opus 5.5 --- scripts/packages.test.mjs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/scripts/packages.test.mjs b/scripts/packages.test.mjs index e4c61dec..c9afdc95 100644 --- a/scripts/packages.test.mjs +++ b/scripts/packages.test.mjs @@ -1,8 +1,8 @@ import { deepEqual, equal, throws } from "node:assert/strict"; import { describe, it } from "node:test"; +import { readFileSync, realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { join } from "node:path"; -import { pathToFileURL } from "node:url"; -import { readFileSync } from "node:fs"; // The manifests that the workspaces publish, and how Node.js resolves the workspaces // (linked into node_modules) through them, as it resolves the installed packages @@ -34,14 +34,17 @@ describe("the manifests of the workspaces", () => { it("resolve the root and package.json, and no other module", () => { for (const { dir, manifest } of packages) { const { name } = manifest; + // The workspace itself, whatever links (or junctions on Windows) lead to it + const resolve = (specifier) => + realpathSync(fileURLToPath(import.meta.resolve(specifier))); equal( - import.meta.resolve(name), - pathToFileURL(join(root, dir, "dist/index.js")).href, + resolve(name), + realpathSync(join(root, dir, "dist/index.js")), name, ); equal( - import.meta.resolve(`${name}/package.json`), - pathToFileURL(join(root, dir, "package.json")).href, + resolve(`${name}/package.json`), + realpathSync(join(root, dir, "package.json")), name, ); for (const path of [ From 5c85e92b3478fb0ad4b20bf63e566befeb273ce0 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 03:10:13 +0300 Subject: [PATCH 188/675] build: check the rules of the published files in the release archives `npm run pack:check` refused tests, secrets and source maps with local paths, but `release.mjs` compared the archives only with `scripts/pack-files.json`: a release archive that broke the rules (e.g. a file listed there by mistake) would have been published. `pack` and the checks of `publish` now apply the same rules to the contents of every archive. Co-Authored-By: Claude Opus 5.5 --- scripts/release.mjs | 33 +++++++++++++++++++++++++++------ scripts/release.test.mjs | 26 +++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/scripts/release.mjs b/scripts/release.mjs index 02c4c5ab..b41e85ac 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -20,15 +20,16 @@ // Everything is logged to stderr: @semantic-release/exec parses the stdout of `publishCmd`. import { compareFiles, - inspectTarball, integrity, manifestFile, readRelease, + readTarball, } from "./tarballs.mjs"; import { join, resolve } from "node:path"; import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { npm as execNpm } from "./npm.mjs"; +import { packageErrors } from "./pack-rules.mjs"; const root = join(import.meta.dirname, ".."); const dependencyFields = [ @@ -220,8 +221,23 @@ function assertCommitted(packages) { } } -// The differences between an archive (`inspectTarball`) and the expected package -function archiveErrors(file, { manifest, files }, expected) { +// The package.json, the sorted file list and the contents of the files of an archive +function readArchive(data) { + const contents = readTarball(data); + const manifest = contents.get("package.json"); + if (typeof manifest === "undefined") { + throw new Error("No package.json in the tarball"); + } + return { + manifest: JSON.parse(manifest), + files: [...contents.keys()].sort(), + contents, + }; +} + +// The differences between an archive (`readArchive`) and the expected package, and +// the files that break the rules of the published files (scripts/pack-rules.mjs) +function archiveErrors(file, { manifest, files, contents }, expected) { const errors = []; for (const field of ["name", "version", "gitHead"]) { if (manifest[field] !== expected[field]) { @@ -234,7 +250,12 @@ function archiveErrors(file, { manifest, files }, expected) { { [expected.name]: expected.files }, { [expected.name]: files }, ); - return [...errors, ...differences.map((error) => `${file}: ${error}`)]; + const rules = packageErrors(files, (path) => contents.get(path)); + return [ + ...errors, + ...differences.map((error) => `${file}: ${error}`), + ...rules.map((error) => `${file}: ${error}`), + ]; } function expectedFiles() { @@ -260,7 +281,7 @@ function pack(dir) { const archive = { file, integrity: integrity(data), - ...inspectTarball(data), + ...readArchive(data), }; const { name } = archive.manifest; if (archives.has(name)) { @@ -343,7 +364,7 @@ function releaseArchives(dir, packages, version, head) { ); } errors.push( - ...archiveErrors(entry.file, inspectTarball(readFileSync(entry.path)), { + ...archiveErrors(entry.file, readArchive(readFileSync(entry.path)), { name: entry.name, version: entry.version, gitHead: entry.sha, diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs index 3ef30f33..063b8783 100644 --- a/scripts/release.test.mjs +++ b/scripts/release.test.mjs @@ -170,7 +170,12 @@ describe("scripts/release.mjs", () => { root = mkdtempSync(join(tmpdir(), "binden-release-")); out = path("release"); mkdirSync(path("scripts")); - for (const script of ["release.mjs", "npm.mjs", "tarballs.mjs"]) { + for (const script of [ + "release.mjs", + "npm.mjs", + "pack-rules.mjs", + "tarballs.mjs", + ]) { copyFileSync( join(import.meta.dirname, script), path(`scripts/${script}`), @@ -489,6 +494,25 @@ describe("scripts/release.mjs", () => { equal(existsSync(join(out, manifestFile)), false); }); + it("refuses archives with files that must not be published", () => { + // Listed as expected: the rules refuse it all the same + writeFileSync(path("packages/a/secret.pem"), "secret"); + writeJSON("scripts/pack-files.json", { + "@fixture/a": [...files, "secret.pem"], + "@fixture/b": files, + "@fixture/c": files, + }); + commit("secret"); + succeed(["version", "2.0.0"]); + const { status, stderr } = release(["pack", out]); + equal(status, 1); + match( + stderr, + /fixture-a-2\.0\.0\.tgz: secret\.pem: \S.* must not be published/u, + ); + equal(existsSync(join(out, manifestFile)), false); + }); + it("fails when npm cannot pack", () => { succeed(["version", "2.0.0"]); const { status } = release(["pack", out], { FAKE_NPM_FAIL_PACK: "1" }); From 9035431c7486c4c1efd5e65cb085da9936b9f28c Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 03:10:17 +0300 Subject: [PATCH 189/675] docs: describe the published files in the migration and contributing guides The migration guide states that the packages no longer require an npm version, are free of side effects and publish their sources with source and declaration maps. The contributing guide explains `npm run pack:check`, its snapshot and why it copies the guides into binden. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 4 ++++ MIGRATION-4.md | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2cb7ccf5..e3035e9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,6 +43,10 @@ depends on `@binden/logger`, and the middlewares on `binden`). TypeScript examples of the READMEs are type checked (`npm run consumer:check`). - **Style.** ESLint and Prettier check the code (`npm run eslint`, `npm run prettier`); the pre-commit hook fixes the staged files. +- **Published files.** `npm run pack:check` compares the files of every package with + [`scripts/pack-files.json`](scripts/pack-files.json) (`node scripts/pack-check.mjs --update` + rewrites it) and refuses tests, secrets and source maps with local paths. It copies + `MIGRATION-4.md` and `PRODUCTION.md` into `packages/binden` first: `binden` publishes them. Before opening a pull request, run everything that CI runs: diff --git a/MIGRATION-4.md b/MIGRATION-4.md index 92e86635..6bb8ea78 100644 --- a/MIGRATION-4.md +++ b/MIGRATION-4.md @@ -57,6 +57,8 @@ of every release are the [GitHub Releases](https://github.com/binden-js/binden/r ### Node.js 24.14.1 Every package requires Node.js `>=24.14.1` (see the table above for the earlier requirements). +The packages no longer declare an `npm` version in their `engines`: any npm that supports Node.js +24 installs them. ### Entry points @@ -71,7 +73,11 @@ which exports all of the public API. ``` With TypeScript, use the `"nodenext"` (or `"bundler"`) module resolution, which reads the -`exports` maps. The types of `pino` (used by `@binden/logger`) need `skipLibCheck` with +`exports` maps. + +The packages declare `"sideEffects": false` (a bundler may drop what an application does not +use), and they publish their TypeScript sources with source maps and declaration maps: stack +traces and "go to definition" lead to the sources rather than to `dist`. The types of `pino` (used by `@binden/logger`) need `skipLibCheck` with `@types/node` 26 (see the [known issues](https://github.com/binden-js/binden#known-issues)). ### MIT license From a873ecd541c03759d1f40c2db5e5d7fb646e9700 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 02:51:31 +0300 Subject: [PATCH 190/675] docs: document every export and fail the API docs on a missing comment Every exported class, member, function, interface property, type alias and constant of the five packages has a JSDoc comment now: what it is and when to use it, with its errors (for example the TypeError of an invalid option). TypeDoc validates `notDocumented` for every package, and warnings are errors, so `npm run docs:build` fails on a new export without a comment. The validation is a package option: with the `packages` entry point strategy, a root-level one has no effect. Only comments are added (and a few inline object types are split over lines to hold them). Co-Authored-By: Claude Opus 5.5 --- packages/binden/src/binden.ts | 21 +++++++- packages/binden/src/context.ts | 43 +++++++++++++++ packages/binden/src/error.ts | 10 ++++ packages/binden/src/exchange.ts | 22 ++++++++ packages/binden/src/headers/accept-charset.ts | 4 ++ .../binden/src/headers/accept-encoding.ts | 14 +++++ .../binden/src/headers/accept-language.ts | 4 ++ packages/binden/src/headers/accept.ts | 7 +++ packages/binden/src/headers/authorization.ts | 13 +++++ .../binden/src/headers/content-encoding.ts | 3 ++ packages/binden/src/headers/content-range.ts | 11 ++++ packages/binden/src/headers/content-type.ts | 16 ++++++ packages/binden/src/headers/cookie.ts | 53 +++++++++++++++++++ packages/binden/src/headers/forwarded.ts | 20 +++++++ .../binden/src/headers/if-modified-since.ts | 3 ++ packages/binden/src/headers/list.ts | 2 + packages/binden/src/headers/range.ts | 17 ++++++ packages/binden/src/logger.ts | 5 +- packages/binden/src/middleware.ts | 10 ++++ packages/binden/src/proxy.ts | 4 ++ packages/binden/src/request.ts | 16 +++++- packages/binden/src/response.ts | 14 +++++ packages/binden/src/route.ts | 3 ++ packages/binden/src/router.ts | 23 ++++++++ packages/binden/src/stack.ts | 26 ++++++--- packages/body/index.ts | 14 +++++ packages/compression/src/codings.ts | 4 ++ packages/compression/src/response.ts | 5 ++ packages/logger/index.ts | 32 +++++++++++ packages/multipart/src/disk.ts | 18 +++++++ packages/multipart/src/info.ts | 2 + packages/multipart/src/options.ts | 6 +++ typedoc.json | 3 +- 33 files changed, 436 insertions(+), 12 deletions(-) diff --git a/packages/binden/src/binden.ts b/packages/binden/src/binden.ts index 59c98aac..1c22218d 100644 --- a/packages/binden/src/binden.ts +++ b/packages/binden/src/binden.ts @@ -37,6 +37,10 @@ import { kSeal } from "./seal.js"; import { isClosing, register, track } from "./shutdown.js"; import { type ILayer, layersOf, markRoot, Stack } from "./stack.js"; +/** + * The status of the reply to an error without a valid status of its own (and + * to a failed or late error handler): `500 Internal Server Error` + */ export const DefaultErrorCode = 500; /** Replaces the default reply to an error (see `IBindenOptions.error_handler`) */ @@ -121,8 +125,14 @@ export interface IBindenBaseOptions< export type IBindenOptions> = IBindenBaseOptions & (Record extends State - ? { state?: (() => State) | undefined } - : { state: () => State }); + ? { + /** Creates the state of every request (an empty object by default) */ + state?: (() => State) | undefined; + } + : { + /** Creates the state of every request: it has required fields */ + state: () => State; + }); /** Thrown through the stack once the request has been aborted: the stack stops */ const ABORTED = new Error("The request has been aborted"); @@ -171,6 +181,11 @@ export class Binden< readonly #Response: typeof BindenResponse; readonly #request_id: IRequestOptions["request_id"]; + /** + * An application with its options (see `IBindenOptions`), checked: an + * invalid one throws a `TypeError` (or a `RangeError` for a value out of + * its range, e.g. a negative `timeout`) + */ public constructor( ...[options = {} as IBindenOptions]: Record< never, @@ -294,7 +309,9 @@ export class Binden< * `callback()` handles the requests of (e.g. `http2` is not supported) */ public serverOptions(): { + /** The request class of the application (with its request options) */ IncomingMessage: typeof BindenRequest; + /** The response class of the application (with its response options) */ ServerResponse: typeof BindenResponse; } { return { IncomingMessage: this.#Request, ServerResponse: this.#Response }; diff --git a/packages/binden/src/context.ts b/packages/binden/src/context.ts index 9385a3b5..3b085b2d 100644 --- a/packages/binden/src/context.ts +++ b/packages/binden/src/context.ts @@ -13,11 +13,18 @@ import type { } from "./response.js"; import type { ISendFileOptions } from "./send-file.js"; +/** The request and the response that a context handles */ export interface IBaseContext { + /** The request being handled */ readonly request: BindenRequest; + /** The response to the request */ readonly response: BindenResponse; } +/** + * The options of a `Context`: its request and response, and what the + * application sets up for them + */ export interface IContextOptions extends IBaseContext { /** The logger whose child (with the `trace_id` of the context) is `context.log` */ readonly logger?: ILogger | undefined; @@ -33,8 +40,15 @@ export interface IContextOptions extends IBaseContext { readonly report?: IExchangeReport | undefined; } +/** The parameters of the matched patterns, by name (see `context.params`) */ export type IParams = Record; +/** + * What a middleware handles: a request and its response, with the state, the + * parameters, the logger, the abort signal and the deadline of the request, and + * helpers to reply. Every context of a request shares them (a middleware may + * return a new one). + */ export class Context< State extends object = Record, Params extends IParams = IParams, @@ -45,6 +59,11 @@ export class Context< readonly #exchange: Exchange; #logger?: ILogger; + /** + * A context of a request and its response: the first one of the request + * creates what the contexts share (a `TypeError` for a response that is not + * the one of the request) + */ public constructor({ request, response, @@ -77,24 +96,29 @@ export class Context< return this.#logger; } + /** Execute `response.setHeader()`: set a header of the response */ public setHeader(name: string, value: IHeadersValue): this { this.response.setHeader(name, value); return this; } + /** Execute `response.status()`: set the status code of the response */ public status(code: number): this { this.response.status(code); return this; } + /** The request being handled */ public get request(): BindenRequest { return this.#request; } + /** The response to the request */ public get response(): BindenResponse { return this.#response; } + /** The id of the request (see `request.id`): the `trace_id` of `log` */ public get id(): string { return this.request.id; } @@ -104,6 +128,7 @@ export class Context< return this.#exchange.done; } + /** Marks the request as handled (it cannot be unset): the stack stops then */ public set done(done: unknown) { this.#exchange.done = Boolean(done); } @@ -148,10 +173,15 @@ export class Context< this.#exchange.onCleanup(cleanup); } + /** + * The URL of the request (see `request.URL`): a `BindenError` (400) when the + * request-target or its host is invalid + */ public get url(): URL { return this.request.URL; } + /** Execute `response.send()` and set `done` */ public async send( data?: Buffer | Readable | bigint | number | string, ): Promise { @@ -173,14 +203,20 @@ export class Context< }); } + /** Execute `response.text()` (`text/plain; charset=utf-8`) and set `done` */ public async text(data: string): Promise { await this.#reply(() => this.response.text(data)); } + /** Execute `response.html()` (`text/html; charset=utf-8`) and set `done` */ public async html(data: string): Promise { await this.#reply(() => this.response.html(data)); } + /** + * Execute `response.form()` (`application/x-www-form-urlencoded`) and set + * `done` + */ public async form(data: URLSearchParams): Promise { await this.#reply(() => this.response.form(data)); } @@ -237,6 +273,13 @@ export class Context< return this; } + /** + * Throws a `BindenError` of the status and the options, which the error + * handler replies with + * + * @throws {BindenError} always (or the `TypeError`/`RangeError` of an invalid + * status) + */ // eslint-disable-next-line class-methods-use-this public throw(status: number, opts?: IBindenErrorOptions): never { throw new BindenError(status, opts); diff --git a/packages/binden/src/error.ts b/packages/binden/src/error.ts index 91b03142..606d9b54 100644 --- a/packages/binden/src/error.ts +++ b/packages/binden/src/error.ts @@ -82,6 +82,11 @@ export class BindenError extends Error { readonly #json: Record | null; readonly #status: number; + /** + * An error of an HTTP status (400 to 599, a `RangeError` otherwise) whose + * reply the options describe: invalid options (e.g. a forbidden header) throw + * a `TypeError` + */ public constructor( status: number, { @@ -128,14 +133,19 @@ export class BindenError extends Error { return this.#headers; } + /** + * Whether the default error handler sends `json` or the message as the body + */ public get expose(): boolean { return this.#expose; } + /** The status code of the reply: 400 to 599 */ public get status(): number { return this.#status; } + /** The JSON body sent when `expose` is `true` (a shallow copy), or `null` */ public get json(): Record | null { return this.#json ? { ...this.#json } : null; } diff --git a/packages/binden/src/exchange.ts b/packages/binden/src/exchange.ts index bdd5f19e..98d501a7 100644 --- a/packages/binden/src/exchange.ts +++ b/packages/binden/src/exchange.ts @@ -8,12 +8,18 @@ export type IAbortReason = "client_closed" | "deadline" | "shutdown"; export class BindenAbortError extends Error { readonly #reason: IAbortReason; + /** The abort of a request, for the reason */ public constructor(reason: IAbortReason) { super(`The request has been aborted (${reason})`); this.name = "BindenAbortError"; this.#reason = reason; } + /** + * Why the request has been aborted: the client has gone away + * (`client_closed`), the deadline has passed (`deadline`) or the server is + * shutting down (`shutdown`) + */ public get reason(): IAbortReason { return this.#reason; } @@ -22,9 +28,11 @@ export class BindenAbortError extends Error { /** Reports a failure that nothing else can handle (e.g. a cleanup that throws) */ export type IExchangeReport = (message: string, error: unknown) => void; +/** The options of an exchange, used when it is created */ export interface IExchangeOptions { /** The state of the request, created once for the exchange */ readonly state?: object | undefined; + /** Reports a failure of a cleanup (ignored by default) */ readonly report?: IExchangeReport | undefined; } @@ -126,14 +134,20 @@ export class Exchange { return exchanges.get(request) ?? null; } + /** + * Aborted once, with a `BindenAbortError`, when the work for the request + * should stop + */ public get signal(): AbortSignal { return this.#controller.signal; } + /** The state of the request */ public get state(): object { return this.#state; } + /** Whether a middleware has handled the request */ public get done(): boolean { return this.#done; } @@ -148,6 +162,7 @@ export class Exchange { return this.#params; } + /** Sets the parameters of the current match (a frozen copy) */ public set params(params: Readonly>) { this.#params = Object.freeze({ ...params }); } @@ -157,6 +172,7 @@ export class Exchange { return this.#mount_path; } + /** Sets the matched mount prefixes, with their values */ public set mount_path(path: string) { this.#mount_path = path; } @@ -166,6 +182,7 @@ export class Exchange { return this.#mount_template; } + /** Sets the matched mount prefixes, as registered */ public set mount_template(template: string) { this.#mount_template = template; } @@ -207,10 +224,15 @@ export class Exchange { return this.#route; } + /** Sets the route that the access log names */ public set route(route: string) { this.#route = route; } + /** + * Where the exchange is: `running`, then `finalizing` (its cleanups run) once + * the response is closed, then `finished` + */ public get phase(): IPhase { return this.#phase; } diff --git a/packages/binden/src/headers/accept-charset.ts b/packages/binden/src/headers/accept-charset.ts index d80672a3..bce993ec 100644 --- a/packages/binden/src/headers/accept-charset.ts +++ b/packages/binden/src/headers/accept-charset.ts @@ -4,6 +4,7 @@ import { isQvalue, preferred, token, weighted } from "./list.js"; export interface IAcceptCharset { /** A charset (a token), or `*` */ charset: string; + /** The weight: a qvalue from 0 to 1 (none by default, which weighs 1) */ q_value?: number | null | undefined; } @@ -26,14 +27,17 @@ export class AcceptCharset implements IAcceptCharset { this.#q_value = q_value; } + /** The charset (a token), or `*` */ public get charset(): string { return this.#charset; } + /** The weight, or `null` without one (it weighs 1) */ public get q_value(): number | null { return this.#q_value; } + /** The element as in the header: `charset;q=weight` */ public toString(): string { const { charset, q_value } = this; return q_value === null ? charset : `${charset};q=${q_value}`; diff --git a/packages/binden/src/headers/accept-encoding.ts b/packages/binden/src/headers/accept-encoding.ts index 85d42aab..de32dc33 100644 --- a/packages/binden/src/headers/accept-encoding.ts +++ b/packages/binden/src/headers/accept-encoding.ts @@ -6,7 +6,9 @@ export type IAcceptEncodings = /** An element of `Accept-Encoding` */ export interface IAcceptEncoding { + /** A known content coding in lowercase, or `*` (any other coding) */ encoding: IAcceptEncodings; + /** The weight: a qvalue from 0 to 1 (none by default, which weighs 1) */ q_value?: number | null; } @@ -15,6 +17,10 @@ export class AcceptEncoding implements IAcceptEncoding { readonly #encoding: IAcceptEncodings; readonly #q_value: number | null; + /** + * A known content coding (or `*`) and its weight (a qvalue): invalid ones + * throw a `TypeError` + */ public constructor({ encoding, q_value = null }: IAcceptEncoding) { if (!AcceptEncoding.#encodings.includes(encoding)) { throw new TypeError(`Unknown content coding: ${String(encoding)}`); @@ -25,19 +31,27 @@ export class AcceptEncoding implements IAcceptEncoding { this.#q_value = q_value; } + /** The content coding, or `*` */ public get encoding(): IAcceptEncodings { return this.#encoding; } + /** The weight, or `null` without one (it weighs 1) */ public get q_value(): number | null { return this.#q_value; } + /** The element as in the header: `coding;q=weight` */ public toString(): string { const { q_value, encoding } = this; return typeof q_value === "number" ? `${encoding};q=${q_value}` : encoding; } + /** + * The elements of the header with a known coding (in lowercase) and a valid + * weight, from the greatest weight (a missing one is 1), in their order on a + * tie + */ public static fromString(string?: string[] | string): AcceptEncoding[] { return AcceptEncoding.#parse(string) .filter((e): e is [IAcceptEncodings, number | null] => diff --git a/packages/binden/src/headers/accept-language.ts b/packages/binden/src/headers/accept-language.ts index 452e75db..20e033f0 100644 --- a/packages/binden/src/headers/accept-language.ts +++ b/packages/binden/src/headers/accept-language.ts @@ -4,6 +4,7 @@ import { isQvalue, preferred, weighted } from "./list.js"; export interface IAcceptLanguage { /** A language range: `*`, or a language tag (e.g. `en`, `en-US`) */ language: string; + /** The weight: a qvalue from 0 to 1 (none by default, which weighs 1) */ q_value?: number | null | undefined; } @@ -30,14 +31,17 @@ export class AcceptLanguage implements IAcceptLanguage { this.#q_value = q_value; } + /** The language range: a language tag, or `*` */ public get language(): string { return this.#language; } + /** The weight, or `null` without one (it weighs 1) */ public get q_value(): number | null { return this.#q_value; } + /** The element as in the header: `language;q=weight` */ public toString(): string { const { language, q_value } = this; return q_value === null ? language : `${language};q=${q_value}`; diff --git a/packages/binden/src/headers/accept.ts b/packages/binden/src/headers/accept.ts index 9e75b8c4..30403e32 100644 --- a/packages/binden/src/headers/accept.ts +++ b/packages/binden/src/headers/accept.ts @@ -13,6 +13,7 @@ export interface IAccept { type: string; /** The parameters of the media range (their names are case-insensitive) */ parameters?: Readonly> | undefined; + /** The weight: a qvalue from 0 to 1 (none by default, which weighs 1) */ q_value?: number | null | undefined; } @@ -62,6 +63,7 @@ export class Accept implements IAccept { this.#q_value = q_value; } + /** The media range (in lowercase when parsed) */ public get type(): string { return this.#type; } @@ -71,10 +73,15 @@ export class Accept implements IAccept { return Object.fromEntries(this.#parameters); } + /** The weight, or `null` without one (it weighs 1) */ public get q_value(): number | null { return this.#q_value; } + /** + * The element as in the header: `type/subtype;name=value;q=weight`, with the + * values that are not tokens quoted + */ public toString(): string { const parameters = [...this.#parameters].map( ([name, value]) => `;${name}=${quote(value)}`, diff --git a/packages/binden/src/headers/authorization.ts b/packages/binden/src/headers/authorization.ts index 00cc42c5..2903975f 100644 --- a/packages/binden/src/headers/authorization.ts +++ b/packages/binden/src/headers/authorization.ts @@ -15,7 +15,12 @@ export type IAuthenticationType = /** The scheme and the credentials of `Authorization` */ export interface IAuthorization { + /** The authentication scheme, in its usual casing (e.g. `Bearer`) */ type: IAuthenticationType; + /** + * What follows the scheme (a token68 or auth-params, as received), or `null` + * (by default) for none + */ credentials?: string | null; } @@ -44,20 +49,28 @@ export class Authorization implements IAuthorization { this.#credentials = credentials; } + /** The authentication scheme, in its usual casing */ public get type(): IAuthenticationType { return this.#type; } + /** The credentials as received (e.g. the base64 of `Basic`), or `null` */ public get credentials(): string | null { return this.#credentials; } + /** The field value: the scheme, then a space and the credentials */ public toString(): string { return typeof this.#credentials === "string" ? `${this.#type} ${this.#credentials}` : this.#type; } + /** + * The header (its scheme in any case), or `null` when it is missing, + * malformed, of an unsupported scheme or with invalid credentials (e.g. + * control characters) + */ public static fromString(input?: string): Authorization | null { // auth-scheme [ 1*SP ( token68 / #auth-param ) ] (RFC 9110, section 11.4) const { scheme = "", rest = "" } = diff --git a/packages/binden/src/headers/content-encoding.ts b/packages/binden/src/headers/content-encoding.ts index f1ae7f4d..4d041bd0 100644 --- a/packages/binden/src/headers/content-encoding.ts +++ b/packages/binden/src/headers/content-encoding.ts @@ -4,6 +4,7 @@ export type IEncodings = /** A coding of `Content-Encoding` */ export interface IContentEncoding { + /** A known content coding, in lowercase */ encoding: IEncodings; } @@ -19,10 +20,12 @@ export class ContentEncoding implements IContentEncoding { this.#encoding = encoding; } + /** The content coding */ public get encoding(): IEncodings { return this.#encoding; } + /** The coding as in the header */ public toString(): string { return this.#encoding; } diff --git a/packages/binden/src/headers/content-range.ts b/packages/binden/src/headers/content-range.ts index 642b760b..b4bbb65a 100644 --- a/packages/binden/src/headers/content-range.ts +++ b/packages/binden/src/headers/content-range.ts @@ -2,6 +2,7 @@ import { Range, type IRange } from "./range.js"; /** The range of `Content-Range` (none when unsatisfied) and the size of the content */ export interface IContentRange extends IRange { + /** The size of the whole content in bytes, or `*` when it is unknown */ readonly size: number | "*"; } @@ -9,6 +10,11 @@ export interface IContentRange extends IRange { export class ContentRange extends Range implements IContentRange { readonly #size: number | "*"; + /** + * The range of `start` to `end` (non-negative integers, `end` within `size`), + * or neither of them (an unsatisfied range), and the size of the content: + * anything else throws a `TypeError` + */ public constructor({ start = null, end = null, size }: IContentRange) { if (typeof start === "number" && !Number.isSafeInteger(start)) { throw new TypeError("`start` is not an integer"); @@ -40,10 +46,15 @@ export class ContentRange extends Range implements IContentRange { this.#size = size; } + /** The size of the whole content in bytes, or `*` */ public get size(): number | "*" { return this.#size; } + /** + * The field value: `bytes start-end/size`, or `bytes *\/size` for an + * unsatisfied range + */ public toString(): string { return typeof this.start === "number" && typeof this.end === "number" ? `${this.unit} ${this.start}-${this.end}/${this.size}` diff --git a/packages/binden/src/headers/content-type.ts b/packages/binden/src/headers/content-type.ts index cf20e94f..a4451e47 100644 --- a/packages/binden/src/headers/content-type.ts +++ b/packages/binden/src/headers/content-type.ts @@ -2,8 +2,17 @@ import { split, unquote } from "./list.js"; /** A media type of `Content-Type` and its parameters */ export interface IContentType { + /** The media type: `type/subtype`, of tokens */ type: string; + /** + * The `charset` parameter, or `null` (by default): not of + * `multipart/form-data` + */ charset?: string | null; + /** + * The `boundary` parameter: required by `multipart/form-data`, ignored for + * any other type + */ boundary?: string | null; /** Other parameters (their names are case-insensitive) */ parameters?: Readonly>; @@ -80,14 +89,17 @@ export class ContentType implements IContentType { this.#parameters = all; } + /** The media type (in lowercase when parsed) */ public get type(): string { return this.#type; } + /** The `charset` parameter (in lowercase when parsed), or `null` */ public get charset(): string | null { return this.#charset; } + /** The `boundary` of `multipart/form-data`, or `null` for any other type */ public get boundary(): string | null { return this.#boundary; } @@ -97,6 +109,10 @@ export class ContentType implements IContentType { return Object.fromEntries(this.#parameters); } + /** + * The field value: the media type and its parameters, joined by `; `, with + * the values that are not tokens quoted + */ public toString(): string { return [ this.type, diff --git a/packages/binden/src/headers/cookie.ts b/packages/binden/src/headers/cookie.ts index 6665f77e..95bbb8e1 100644 --- a/packages/binden/src/headers/cookie.ts +++ b/packages/binden/src/headers/cookie.ts @@ -1,13 +1,39 @@ /** A cookie: its name, value and attributes (RFC 6265) */ export interface ICookie { + /** The name of the cookie: a token */ readonly key: string; + /** + * The value: cookie-octets (no spaces, `"`, `,`, `;` or `\`), maybe in double + * quotes + */ readonly value: string; + /** + * When the cookie expires (`Expires`) unless `max_age` is set, or `null` (by + * default) for a session cookie + */ readonly expires?: Date | null; + /** + * The lifetime in seconds (`Max-Age`, an integer), which takes precedence + * over `expires`, or `null` (by default) + */ readonly max_age?: number | null; + /** + * The domain whose hosts get the cookie (`Domain`), or `null` (by default) + * for the host of the origin only + */ readonly domain?: string | null; + /** + * The path prefix of the requests that get the cookie (`Path`, `/` by + * default), or `null` + */ readonly path?: string | null; + /** + * Whether the cookie is sent over HTTPS only (`Secure`, `false` by default) + */ readonly secure?: boolean; + /** Whether scripts cannot read the cookie (`HttpOnly`, `true` by default) */ readonly http_only?: boolean; + /** Which cross-site requests get the cookie (`SameSite`, `Lax` by default) */ readonly same_site?: "Lax" | "None" | "Strict"; /** Partitioned by the top-level site (CHIPS): implies `Secure` */ readonly partitioned?: boolean; @@ -42,6 +68,11 @@ export class Cookie implements ICookie { readonly #same_site: "Lax" | "None" | "Strict"; readonly #partitioned: boolean; + /** + * A cookie with its attributes (by default `Path=/`, `HttpOnly` and + * `SameSite=Lax`): a name, a value or an attribute that could add attributes + * to `Set-Cookie` throws a `TypeError` + */ public constructor({ key, value, @@ -83,30 +114,40 @@ export class Cookie implements ICookie { this.#expires = expires instanceof Date ? new Date(expires) : null; } + /** The name of the cookie */ public get key(): string { return this.#key; } + /** The value of the cookie */ public get value(): string { return this.#value; } + /** When the cookie expires (a copy), or `null` */ public get expires(): Date | null { return this.#expires ? new Date(this.#expires) : null; } + /** The lifetime in seconds, or `null` */ public get max_age(): number | null { return this.#max_age; } + /** The `Domain` attribute, or `null` (always for a `__Host-` cookie) */ public get domain(): string | null { return this.key.startsWith("__Host-") ? null : this.#domain; } + /** The `Path` attribute, or `null` (always `/` for a `__Host-` cookie) */ public get path(): string | null { return this.key.startsWith("__Host-") ? "/" : this.#path; } + /** + * Whether the cookie has `Secure`: also implied by the `__Secure-` and + * `__Host-` prefixes, `SameSite=None` and `Partitioned` + */ public get secure(): boolean { return ( this.key.startsWith("__Secure-") || @@ -117,18 +158,25 @@ export class Cookie implements ICookie { ); } + /** Whether the cookie has `HttpOnly` */ public get http_only(): boolean { return this.#http_only; } + /** The `SameSite` attribute */ public get same_site(): "Lax" | "None" | "Strict" { return this.#same_site; } + /** Whether the cookie has `Partitioned` */ public get partitioned(): boolean { return this.#partitioned; } + /** + * The value of a `Set-Cookie` field: `name=value` and the attributes + * (`Max-Age` instead of `Expires` when both are set) + */ public toString(): string { let output = `${this.key}=${this.value}`; output += @@ -146,6 +194,11 @@ export class Cookie implements ICookie { return output; } + /** + * The valid cookies of a `Cookie` header (`name=value` pairs, separated by + * `;`), with the double quotes of their values removed: the first cookie of a + * name wins, and invalid ones are skipped + */ public static fromString(input?: string): Cookie[] { if (typeof input !== "string") { return []; diff --git a/packages/binden/src/headers/forwarded.ts b/packages/binden/src/headers/forwarded.ts index 28b48f3e..bc4cc827 100644 --- a/packages/binden/src/headers/forwarded.ts +++ b/packages/binden/src/headers/forwarded.ts @@ -2,10 +2,25 @@ import { fieldValue, quote, split, unquote } from "./list.js"; /** The parameters of a `Forwarded` element */ export interface IForwarded { + /** + * The client of the proxy (`for`): an address (maybe with a port), `unknown` + * or an obfuscated identifier + */ for: string; + /** + * The interface of the proxy that has received the request (`by`), or `null` + */ by?: string | null; + /** The `Host` that the proxy has received (`host`), or `null` */ host?: string | null; + /** + * The protocol that the proxy has received (`proto`, e.g. `https`), or `null` + */ proto?: string | null; + /** + * The `secret` extension parameter (e.g. a token shared with the proxy), or + * `null` + */ secret?: string | null; } @@ -37,22 +52,27 @@ export class Forwarded implements IForwarded { this.#proto = proto ?? null; } + /** The client of the proxy (`for`), `unknown` when the element has none */ public get for(): string { return this.#for; } + /** The interface of the proxy (`by`), or `null` */ public get by(): string | null { return this.#by ?? null; } + /** The `Host` that the proxy has received (`host`), or `null` */ public get host(): string | null { return this.#host ?? null; } + /** The `secret` extension parameter, or `null` */ public get secret(): string | null { return this.#secret ?? null; } + /** The protocol that the proxy has received (`proto`), or `null` */ public get proto(): string | null { return this.#proto ?? null; } diff --git a/packages/binden/src/headers/if-modified-since.ts b/packages/binden/src/headers/if-modified-since.ts index 710da55f..a3f3dd3a 100644 --- a/packages/binden/src/headers/if-modified-since.ts +++ b/packages/binden/src/headers/if-modified-since.ts @@ -2,6 +2,7 @@ import { parseHttpDate } from "./http-date.js"; /** The date of `If-Modified-Since` */ export interface IIfModifiedSince { + /** The date (the header has a precision of one second) */ readonly date: Date; } @@ -24,10 +25,12 @@ export class IfModifiedSince implements IIfModifiedSince { this.#date = value; } + /** The date (a copy) */ public get date(): Date { return new Date(this.#date); } + /** The date as an HTTP-date (`Sun, 06 Nov 1994 08:49:37 GMT`) */ public toString(): string { return this.#date.toUTCString(); } diff --git a/packages/binden/src/headers/list.ts b/packages/binden/src/headers/list.ts index 59bc4e03..fefe8382 100644 --- a/packages/binden/src/headers/list.ts +++ b/packages/binden/src/headers/list.ts @@ -65,9 +65,11 @@ export function isQvalue(q_value: number | null): boolean { /** An element of a list with weights: its value, parameters and weight */ export interface IWeighted { + /** The value of the element (e.g. a media range), trimmed and not checked */ readonly value: string; /** The parameters before the weight, with their names in lowercase */ readonly parameters: readonly (readonly [string, string])[]; + /** The weight (the `q` parameter), or `null` without one */ readonly q_value: number | null; } diff --git a/packages/binden/src/headers/range.ts b/packages/binden/src/headers/range.ts index 8cfceb7a..ba6ce886 100644 --- a/packages/binden/src/headers/range.ts +++ b/packages/binden/src/headers/range.ts @@ -1,6 +1,14 @@ /** A range of `Range`: its first and last positions, either one optional */ export interface IRange { + /** + * The first position, or `null` (by default) for a suffix range (the last + * `end` bytes) + */ readonly start?: number | null; + /** + * The last position (included), the length of a suffix range, or `null` (by + * default) for the rest of the content + */ readonly end?: number | null; } @@ -37,18 +45,27 @@ export class Range implements IRange { this.#end = end; } + /** The range unit: `bytes` */ public get unit(): IUnit { return this.#unit; } + /** The first position, or `null` for a suffix range */ public get start(): number | null { return this.#start; } + /** + * The last position (included), the length of a suffix range, or `null` for + * the rest of the content + */ public get end(): number | null { return this.#end; } + /** + * The range as in the header: `bytes=start-end`, either of them maybe empty + */ public toString(): string { return `${this.unit}=${this.start ?? ""}-${this.end ?? ""}`; } diff --git a/packages/binden/src/logger.ts b/packages/binden/src/logger.ts index c24e3285..99a79fd9 100644 --- a/packages/binden/src/logger.ts +++ b/packages/binden/src/logger.ts @@ -74,6 +74,7 @@ function typeOf(value: unknown): string { /** The options of a snapshot */ export interface ISnapshotOptions { + /** Where in the framework the error has occurred */ readonly location: ILocation; /** The id of the request, if any */ readonly request_id?: string | undefined; @@ -225,7 +226,7 @@ export function safe(logger: ILogger): ILogger { */ export const serializers = { ...defaultSerializers, - // The type of an error, a known `code` and its `status` + /** The type of an error, a known `code` and its `status` */ error: (error: unknown): Record => { const { type, code, status } = snapshot(error, { location: "middleware", @@ -237,7 +238,9 @@ export const serializers = { ...(typeof status === "number" ? { status } : {}), }; }, + /** A body that the application logs: never its content */ body: (): string => "[Redacted]", + /** The id of a context, with its request and its response (allowlisted) */ context: (input: unknown): Record => { if (!(input instanceof Context)) { return { type: typeOf(input) }; diff --git a/packages/binden/src/middleware.ts b/packages/binden/src/middleware.ts index 3b1e10e5..4ef8bf31 100644 --- a/packages/binden/src/middleware.ts +++ b/packages/binden/src/middleware.ts @@ -30,9 +30,15 @@ export abstract class Middleware< State extends object = Record, Params extends IParams = IParams, > { + /** Whether the middleware is skipped */ public disabled: boolean; + /** Whether the errors of the middleware are ignored (the stack goes on) */ public ignore_errors: boolean; + /** + * A middleware with its options: an option that is not a boolean throws a + * `TypeError` + */ public constructor({ disabled = false, ignore_errors = false, @@ -46,6 +52,10 @@ export abstract class Middleware< this.ignore_errors = ignore_errors; } + /** + * Handles the context: it may reply (and set `done`), or return (or resolve + * to) the context of the next middlewares + */ public abstract run( context: Context, ): IMiddlewareReturnType; diff --git a/packages/binden/src/proxy.ts b/packages/binden/src/proxy.ts index 3dda860b..e670cd41 100644 --- a/packages/binden/src/proxy.ts +++ b/packages/binden/src/proxy.ts @@ -26,13 +26,17 @@ export type ITrustProxy = | boolean | number | { + /** The trusted proxies: `true`, `false`, a number of hops or a function */ trust: ITrustFn | boolean | number; + /** The headers that they set (`forwarded` by default) */ headers?: IProxyHeaders | undefined; }; /** A checked `trust_proxy` option, with its headers */ export interface IProxyTrust { + /** The trusted proxies: `true`, `false`, a number of hops or a function */ readonly trust: ITrustFn | boolean | number; + /** The headers that they set */ readonly headers: IProxyHeaders; } diff --git a/packages/binden/src/request.ts b/packages/binden/src/request.ts index cbff2359..a9f5ce46 100644 --- a/packages/binden/src/request.ts +++ b/packages/binden/src/request.ts @@ -56,7 +56,10 @@ export interface IRequestOptions { */ readonly request_id: IRequestIdSettings | null; /** The greatest number of keys that `request.query` parses (1000) */ - readonly query: { readonly max_keys: number }; + readonly query: { + /** The greatest number of keys (a positive integer, or `Infinity`) */ + readonly max_keys: number; + }; } /** @@ -68,8 +71,11 @@ export type IAllowedHosts = readonly string[] | ((host: string) => boolean); /** The settings of the request ids, checked and with their defaults */ export interface IRequestIdSettings { + /** The name of the field of the id, in lowercase */ readonly header: string; + /** Whether a valid incoming id is used instead of a new one */ readonly trust_incoming: boolean; + /** Whether the response sends the id back */ readonly echo: boolean; } @@ -107,7 +113,12 @@ export interface IRequestOptionsInput { /** The request ids (new ids, not sent back, by default) */ request_id?: IRequestIdOptions | undefined; /** The greatest number of keys that `request.query` parses (`1000`) */ - query?: { max_keys?: number | undefined } | undefined; + query?: + | { + /** The greatest number of keys: a positive integer, or `Infinity` */ + max_keys?: number | undefined; + } + | undefined; } /** Throws unless the value is an object (or `undefined`, for the default) */ @@ -316,6 +327,7 @@ export class BindenRequest extends IncomingMessage { return this.#body; } + /** Sets the parsed body: once only, later values are ignored */ public set body(body: unknown) { if (typeof this.#body === "undefined") { this.#body = body; diff --git a/packages/binden/src/response.ts b/packages/binden/src/response.ts index 29c54c1d..2c60e55e 100644 --- a/packages/binden/src/response.ts +++ b/packages/binden/src/response.ts @@ -19,13 +19,19 @@ import { kSeal } from "./seal.js"; import { type ISendFileOptions, matches, sendFileTo } from "./send-file.js"; import { isStatus } from "./status.js"; +/** The media type of `response.json()` */ export const ct_json = "application/json"; +/** The media type of `response.text()` (sent with `charset=utf-8`) */ export const ct_text = "text/plain"; +/** The media type of `response.html()` (sent with `charset=utf-8`) */ export const ct_html = "text/html"; +/** The media type of `response.form()` */ export const ct_form = "application/x-www-form-urlencoded"; +/** The value of a header: a number, a string, or the strings of its lines */ export type IHeadersValue = number | string | readonly string[]; +/** Headers by name */ export type IHeaders = Record; /** The options of the responses of an application (`Binden` sets them) */ @@ -94,6 +100,11 @@ const fieldName = /^[!#$%&'*+\-.^_`|~\dA-Za-z]+$/u; /** A late send is a no-op */ const always = (): boolean => true; +/** + * A response (`ServerResponse`) with helpers to reply (`send()`, `json()`, + * `sendFile()`, `redirect()`, …) and its cookies: the `ServerResponse` + * class of the servers + */ export class BindenResponse< Request extends IncomingMessage = IncomingMessage, > extends ServerResponse { @@ -101,6 +112,7 @@ export class BindenResponse< #started = false; readonly #seals: (() => boolean)[] = []; + /** A response to the request (created by the server) */ public constructor(...args: [request: Request, options?: unknown]) { // node:http passes its options as well super(...(args as [Request])); @@ -240,6 +252,7 @@ export class BindenResponse< statusMessage?: string, headers?: OutgoingHttpHeader[] | OutgoingHttpHeaders, ): this; + /** Sends the headers without a reason phrase (as the first signature) */ public override writeHead( statusCode: number, headers?: OutgoingHttpHeader[] | OutgoingHttpHeaders, @@ -363,6 +376,7 @@ export class BindenResponse< public send( data?: Buffer | Readable | bigint | number | string, ): Promise; + /** Send a string in an encoding (e.g. `latin1`) */ public send(data: string, encoding: BufferEncoding): Promise; public send( data?: Buffer | Readable | bigint | number | string, diff --git a/packages/binden/src/route.ts b/packages/binden/src/route.ts index 8b73bf22..7947466e 100644 --- a/packages/binden/src/route.ts +++ b/packages/binden/src/route.ts @@ -38,6 +38,7 @@ type ISegment = { readonly literal: string } | { readonly param: string }; export interface IPattern { /** The pattern as registered (a mount prefix without its trailing `/`) */ readonly source: string; + /** The segments, without the `*name` tail */ readonly segments: readonly ISegment[]; /** The segments without a trailing empty one (see the `strict` option) */ readonly loose: readonly ISegment[]; @@ -47,6 +48,7 @@ export interface IPattern { /** The match of a mount prefix */ export interface IPrefixMatch { + /** The parameters of the prefix, decoded once */ readonly params: IParams; /** The matched part of the path, as received (`""` for the prefix `/`) */ readonly prefix: string; @@ -214,6 +216,7 @@ function split(path: string): string[] { * option) */ export interface IMatchOptions extends IRoutingOptions { + /** Whether a parameter may have an encoded `/` or `\` (`false`) */ readonly encoded_slashes?: boolean | undefined; } diff --git a/packages/binden/src/router.ts b/packages/binden/src/router.ts index 59fba630..82bf8601 100644 --- a/packages/binden/src/router.ts +++ b/packages/binden/src/router.ts @@ -22,6 +22,10 @@ export class Router< readonly #middlewares: Map[]>; #guarded: boolean; + /** + * A router without middlewares: a `guarded` that is not a boolean throws a + * `TypeError` + */ public constructor({ guarded = false }: IRouterOptions = {}) { if (typeof guarded !== "boolean") { throw new TypeError("`guarded` is not a boolean"); @@ -30,10 +34,15 @@ export class Router< this.#middlewares = new Map[]>(); } + /** + * Whether a request of a method without middlewares is refused (see + * `IRouterOptions`) + */ public get guarded(): boolean { return this.#guarded; } + /** Sets whether a request of a method without middlewares is refused */ public set guarded(guarded: unknown) { this.#guarded = Boolean(guarded); } @@ -116,34 +125,48 @@ export class Router< return removed; } + /** Adds middlewares for `DELETE` (see `on()`) */ public delete(...middlewares: IMiddleware[]): this { return this.on("DELETE", ...middlewares); } + /** + * Adds middlewares for `GET` (and `HEAD`, without middlewares of its own, see + * `auto_head`) + */ public get(...middlewares: IMiddleware[]): this { return this.on("GET", ...middlewares); } + /** Adds middlewares for `HEAD` (see `on()`) */ public head(...middlewares: IMiddleware[]): this { return this.on("HEAD", ...middlewares); } + /** + * Adds middlewares for `OPTIONS` (instead of the automatic `204` of a guarded + * router) + */ public options(...middlewares: IMiddleware[]): this { return this.on("OPTIONS", ...middlewares); } + /** Adds middlewares for `PATCH` (see `on()`) */ public patch(...middlewares: IMiddleware[]): this { return this.on("PATCH", ...middlewares); } + /** Adds middlewares for `POST` (see `on()`) */ public post(...middlewares: IMiddleware[]): this { return this.on("POST", ...middlewares); } + /** Adds middlewares for `PUT` (see `on()`) */ public put(...middlewares: IMiddleware[]): this { return this.on("PUT", ...middlewares); } + /** Adds middlewares for `TRACE` (see `on()`) */ public trace(...middlewares: IMiddleware[]): this { return this.on("TRACE", ...middlewares); } diff --git a/packages/binden/src/stack.ts b/packages/binden/src/stack.ts index 313765a4..4aa0be75 100644 --- a/packages/binden/src/stack.ts +++ b/packages/binden/src/stack.ts @@ -20,8 +20,14 @@ export type IUntypedItem = Middleware | Router; export type IStackPath = | RegExp | string - | { readonly mount: string } - | { readonly route: string } + | { + /** The prefix of a `mount()` layer, as registered */ + readonly mount: string; + } + | { + /** The pattern of a `route()` layer, as registered */ + readonly route: string; + } | null; /** A layer of the stack: its items, and the path they are used for */ @@ -117,11 +123,12 @@ export class Stack> { }); } + /** Adds middlewares and routers for every path */ + public use(...items: (IStackItem | IUntypedItem)[]): this; /** - * Adds middlewares and routers for every path, for an exact path (a string) - * or for the paths that a RegExp matches + * Adds middlewares and routers for an exact path (a string) or for the + * paths that a RegExp matches */ - public use(...items: (IStackItem | IUntypedItem)[]): this; public use( path: RegExp | string, ...items: (IStackItem | IUntypedItem)[] @@ -202,8 +209,15 @@ export class Stack> { return this; } - /** Removes middlewares and routers added by `use()`: the removed ones */ + /** + * Removes middlewares and routers added by `use()` for every path: the + * removed ones + */ public off(...items: (IStackItem | IUntypedItem)[]): IStackItem[]; + /** + * Removes middlewares and routers added by `use()` for an exact path or a + * RegExp: the removed ones + */ public off( path: RegExp | string, ...items: (IStackItem | IUntypedItem)[] diff --git a/packages/body/index.ts b/packages/body/index.ts index 909d3f10..3c9c74af 100644 --- a/packages/body/index.ts +++ b/packages/body/index.ts @@ -270,6 +270,11 @@ export class BodyParser extends Middleware { readonly #verify_encoded: boolean; readonly #zstd: boolean; + /** + * A body parser with its options (see `IBodyParserOptions`), checked: an + * invalid one throws a `TypeError` (or a `RangeError` for a limit that is not + * a non-negative integer or `Infinity`) + */ public constructor({ parse, limit = Infinity, @@ -320,6 +325,11 @@ export class BodyParser extends Middleware { this.#verify = verify ?? null; } + /** + * Parses the body of the request into `request.body`, unless the method has + * no body, its `Content-Type` is not selected, or it has already been parsed: + * a `BindenError` for a body that cannot be parsed (see the README) + */ public async run(context: Context): Promise { const { request } = context; const log = context.log.child({ middleware: BodyParser.name }); @@ -707,4 +717,8 @@ export class BodyParser extends Middleware { ]; } +/** + * A `BodyParser` with the default options: without limits (set `limit` and + * `encoded_limit` in production) + */ export default new BodyParser(); diff --git a/packages/compression/src/codings.ts b/packages/compression/src/codings.ts index 58cde73d..c914083a 100644 --- a/packages/compression/src/codings.ts +++ b/packages/compression/src/codings.ts @@ -18,9 +18,13 @@ export const codings: readonly ICompressEncodings[] = [ /** The options of the compressor of every coding */ export interface ICompressorOptions { + /** The options of the `br` compressor */ readonly br: BrotliOptions; + /** The options of the `deflate` compressor */ readonly deflate: ZlibOptions; + /** The options of the `gzip` compressor */ readonly gzip: ZlibOptions; + /** The options of the `zstd` compressor */ readonly zstd: ZstdOptions; } diff --git a/packages/compression/src/response.ts b/packages/compression/src/response.ts index 76d328e6..508069f8 100644 --- a/packages/compression/src/response.ts +++ b/packages/compression/src/response.ts @@ -22,10 +22,15 @@ import { negotiate } from "./negotiate.js"; /** The settings of a compression, validated */ export interface ISettings { + /** The fixed content coding, or `auto` to negotiate it */ readonly format: ICompressEncodings | "auto"; + /** The codings that `auto` offers, the preferred first */ readonly encodings: readonly ICompressEncodings[]; + /** The `Content-Length` below which a response is not compressed */ readonly threshold: number; + /** Whether a response is compressed, by its `Content-Type` */ readonly filter: ICompressionFilter; + /** The options of the compressor of every coding */ readonly compressors: ICompressorOptions; } diff --git a/packages/logger/index.ts b/packages/logger/index.ts index a9361a9c..8204a5b2 100644 --- a/packages/logger/index.ts +++ b/packages/logger/index.ts @@ -28,12 +28,19 @@ export interface IChildOptions { /** A logger that binden can use: the `Logger` of this package, or any object with these methods */ export interface ILogger { + /** Logs a message (with extra fields) at the `fatal` level */ fatal(message: string, extra?: Record): unknown; + /** Logs a message (with extra fields) at the `error` level */ error(message: string, extra?: Record): unknown; + /** Logs a message (with extra fields) at the `warn` level */ warn(message: string, extra?: Record): unknown; + /** Logs a message (with extra fields) at the `info` level */ info(message: string, extra?: Record): unknown; + /** Logs a message (with extra fields) at the `debug` level */ debug(message: string, extra?: Record): unknown; + /** Logs a message (with extra fields) at the `trace` level */ trace(message: string, extra?: Record): unknown; + /** A logger that adds the bindings to every record */ child(bindings: Record): ILogger; } @@ -117,8 +124,14 @@ function pathOf(target: string): string { /** The default serializers of the `error`, `request` and `response` keys */ export interface ISerializers { + /** + * The type of an error, a known `code` and its `status` (with + * `error_details`, also its message, stack and any `code`) + */ error(error: unknown): Record; + /** The method and the path (without the query) of a request */ request(request: unknown): Record; + /** The status of a response */ response(response: unknown): Record; } @@ -200,6 +213,12 @@ export class Logger implements ILogger { }; } + /** + * A logger with the options of `pino` (and its safe serializers, merged by + * key with `serializers`), or one of an existing `pino` logger (`logger`): + * `logger` with `destination`, or an `error_details` that is not a boolean, + * throws a `TypeError` + */ public constructor({ logger, destination, @@ -238,6 +257,7 @@ export class Logger implements ILogger { return this.#pino().level as LevelWithSilent; } + /** Sets the level of the logger */ public set level(level: LevelWithSilent) { this.#pino().level = level; } @@ -270,34 +290,46 @@ export class Logger implements ILogger { ); } + /** Logs a message (with extra fields) at the `fatal` level */ public fatal(message: string, extra: Record = {}): void { this.#pino().fatal(extra, message); } + /** Logs a message (with extra fields) at the `error` level */ public error(message: string, extra: Record = {}): void { this.#pino().error(extra, message); } + /** Logs a message (with extra fields) at the `warn` level */ public warn(message: string, extra: Record = {}): void { this.#pino().warn(extra, message); } + /** Logs a message (with extra fields) at the `info` level */ public info(message: string, extra: Record = {}): void { this.#pino().info(extra, message); } + /** Logs a message (with extra fields) at the `debug` level */ public debug(message: string, extra: Record = {}): void { this.#pino().debug(extra, message); } + /** Logs a message (with extra fields) at the `trace` level */ public trace(message: string, extra: Record = {}): void { this.#pino().trace(extra, message); } + /** The environment variable of the default level: `BINDEN_LOG_LEVEL` */ public static get ENV_VARIABLE_NAME(): string { return "BINDEN_LOG_LEVEL"; } + /** + * The level that an environment variable names (in any case, + * `BINDEN_LOG_LEVEL` by default): `silent` without it, or for an unknown + * level + */ public static getLevel(env_name = this.ENV_VARIABLE_NAME): LevelWithSilent { const { env: { [env_name]: LEVEL }, diff --git a/packages/multipart/src/disk.ts b/packages/multipart/src/disk.ts index f062d9c9..241563b1 100644 --- a/packages/multipart/src/disk.ts +++ b/packages/multipart/src/disk.ts @@ -37,11 +37,17 @@ export interface IStoredForm { /** The operations of the file system of the disk storage (replaced by the tests) */ export interface IFileSystem { + /** Creates the private directory of a request (see `fs.mkdtemp()`) */ mkdtemp(prefix: string): Promise; + /** Opens a file (see `fs.open()`) */ open(path: string, flags: number, mode: number): Promise; + /** Creates a hard link (see `fs.link()`) */ link(existing: string, target: string): Promise; + /** Copies a file (see `fs.copyFile()`) */ copyFile(source: string, target: string, mode: number): Promise; + /** Removes a file (see `fs.unlink()`) */ unlink(path: string): Promise; + /** Removes an empty directory (see `fs.rmdir()`) */ rmdir(path: string): Promise; } @@ -78,9 +84,13 @@ async function unlinkIfExists( * never removes a file that is being (or has been) persisted. */ export class StoredFile implements IStoredFile { + /** The name of the field of the file */ public readonly field: string; + /** The name that the client has given to the file (metadata only) */ public readonly filename: string; + /** The media type that the client has declared */ public readonly mime_type: string; + /** The transfer encoding that the client has declared */ public readonly encoding: string; /** Settles once the file is written (and its descriptor closed) */ public readonly written: Promise; @@ -97,6 +107,7 @@ export class StoredFile implements IStoredFile { #stopped = false; #lock: Promise = Promise.resolve(); + /** Starts writing the stream of a file part to a new file of the directory */ public constructor( { field, filename, mime_type, encoding }: IFileInfo, system: IFileSystem, @@ -111,18 +122,25 @@ export class StoredFile implements IStoredFile { this.written = this.#write(directory, stream); } + /** Where the file is (see `IStoredFile.path`) */ public get path(): string { return this.#path; } + /** The size of the file in bytes */ public get size(): number { return this.#size; } + /** Whether the file has been truncated to `fileSize` */ public get truncated(): boolean { return this.#truncated; } + /** + * Moves the file to `target` (see `IStoredFile.persist()`): a `TypeError` for + * a target that is not a non-empty string + */ public async persist(target: string): Promise { if (typeof target !== "string" || target === "") { throw new TypeError("`target` is not a non-empty string"); diff --git a/packages/multipart/src/info.ts b/packages/multipart/src/info.ts index e3d7833e..4fcfa1d8 100644 --- a/packages/multipart/src/info.ts +++ b/packages/multipart/src/info.ts @@ -16,7 +16,9 @@ export interface IMultipartInfo { /** What a parse records while it reads the body */ export interface IParseInfo { + /** The names of the fields truncated to `fieldSize`, in their order */ readonly truncated_fields: string[]; + /** The limits on the count of the parts that have been reached */ readonly limits_hit: ILimitName[]; } diff --git a/packages/multipart/src/options.ts b/packages/multipart/src/options.ts index 3c47f769..fa6ee2c1 100644 --- a/packages/multipart/src/options.ts +++ b/packages/multipart/src/options.ts @@ -118,11 +118,17 @@ export interface IMultipartOptions extends IMiddlewareParams { /** The options of a parse, checked, with their defaults */ export interface IParserOptions { + /** The configuration of busboy */ readonly config: IMultipartConfig; + /** Whether a limit of busboy refuses the request (`413`) */ readonly throw_limits: boolean; + /** The largest body in bytes (`Infinity` for none) */ readonly limit: number; + /** Whether a file is accepted (`null`: all of them) */ readonly file_filter: IFileFilter | null; + /** What happens to a file that `file_filter` refuses */ readonly filtered: "reject" | "skip"; + /** Where the files go */ readonly mode: IMode; } diff --git a/typedoc.json b/typedoc.json index 5917667b..df5b83c0 100644 --- a/typedoc.json +++ b/typedoc.json @@ -12,7 +12,8 @@ "\"http\".Server": "https://nodejs.org/api/http.html#class-httpserver", "\"http\".ClientRequest": "https://nodejs.org/api/http.html#class-httpclientrequest" } - } + }, + "validation": { "notDocumented": true } }, "projectDocuments": ["MIGRATION-4.md", "PRODUCTION.md"], "hideGenerator": true, From 9eee753e9a7b0f84e2a771ae54790097d271e783 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 02:51:42 +0300 Subject: [PATCH 191/675] build: add fast-check to the development dependencies The property-based tests of the header parsers, the request-target, the cookies, the proxy headers and the route matcher generate their inputs with fast-check (a development dependency of the monorepo only: no package depends on it). Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 41 +++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 42 insertions(+) diff --git a/package-lock.json b/package-lock.json index b148135c..53547c83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "c8": "^12.0.0", "conventional-changelog-conventionalcommits": "^9.3.1", "eslint": "^10.12.0", + "fast-check": "^4.10.2", "globals": "^17.13.0", "husky": "^9.1.7", "lint-staged": "^17.6.0", @@ -3680,6 +3681,29 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/fast-check": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz", + "integrity": "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7669,6 +7693,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qified": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", diff --git a/package.json b/package.json index 8a9e160f..a4d94647 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "c8": "^12.0.0", "conventional-changelog-conventionalcommits": "^9.3.1", "eslint": "^10.12.0", + "fast-check": "^4.10.2", "globals": "^17.13.0", "husky": "^9.1.7", "lint-staged": "^17.6.0", From f47fed9095cb14ea1d2ec22a53885679d0e443f2 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 03:10:00 +0300 Subject: [PATCH 192/675] test: check the parsers and serializers of the headers with properties in binden Every header class is checked with fast-check: parsing any string never throws, the constructor accepts every element that the parser returns, and an element is parsed back from its serialization as it is (also for valid generated elements). The negotiations pick one of the offers or none, and a quoted string is unquoted as it was and never split. The properties run a bounded number of times with a fixed seed, which a failure reports with the path of its counterexample (FC_SEED, FC_PATH and FC_RUNS replay or widen a run). Two known gaps are left out of the generated values: the years before 100 of If-Modified-Since, and the unsatisfied Content-Range without a size. Co-Authored-By: Claude Opus 5.5 --- packages/binden/test/property/check.ts | 64 +++ packages/binden/test/property/headers.test.ts | 508 ++++++++++++++++++ 2 files changed, 572 insertions(+) create mode 100644 packages/binden/test/property/check.ts create mode 100644 packages/binden/test/property/headers.test.ts diff --git a/packages/binden/test/property/check.ts b/packages/binden/test/property/check.ts new file mode 100644 index 00000000..72ce76ee --- /dev/null +++ b/packages/binden/test/property/check.ts @@ -0,0 +1,64 @@ +import fc from "fast-check"; + +/** + * The seed of the properties: fixed, so that every run checks the same + * inputs (`FC_SEED=` checks others). A failure reports the seed and + * the path of its counterexample, which `FC_SEED` and `FC_PATH` replay. + */ +const seed = Number.parseInt(process.env.FC_SEED ?? "20261015", 10); + +/** The path of a counterexample to replay (see `seed`) */ +const path = process.env.FC_PATH; + +/** Checks a property with a bounded number of runs (`FC_RUNS` overrides it) */ +export function check( + property: fc.IProperty, + runs = 300, +): void { + fc.assert(property, { + seed, + numRuns: Number.parseInt(process.env.FC_RUNS ?? `${runs}`, 10), + ...(typeof path === "string" ? { path } : {}), + }); +} + +/** Any string: every code point, or the characters that matter to headers */ +export function anyString( + alphabet: readonly string[], + maxLength = 40, +): fc.Arbitrary { + return fc.oneof( + fc.string({ unit: "binary", maxLength }), + fc.string({ unit: "binary-ascii", maxLength }), + fc.string({ unit: fc.constantFrom(...alphabet), maxLength }), + ); +} + +/** The visible ASCII characters (`!` to `~`) */ +export const visible: readonly string[] = Array.from({ length: 94 }, (_, i) => + String.fromCharCode(0x21 + i), +); + +/** + * The characters that a field value may have (RFC 9110, section 5.5): tabs, + * spaces, visible ASCII and obs-text (`\x80` to `\xFF`) + */ +export const fieldValueChar: fc.Arbitrary = fc.oneof( + fc.constantFrom("\t", " ", '"', "\\", ",", ";", "=", "\xA0", "\xFF"), + fc.constantFrom(...visible), + fc.integer({ min: 0x80, max: 0xff }).map((code) => String.fromCharCode(code)), +); + +/** A token (RFC 9110, section 5.6.2) */ +export const token: fc.Arbitrary = fc.string({ + unit: fc.constantFrom( + ..."!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", + ), + minLength: 1, + maxLength: 8, +}); + +/** A weight: a qvalue (RFC 9110, section 12.4.2) or none */ +export const qvalue: fc.Arbitrary = fc.option( + fc.integer({ min: 0, max: 1000 }).map((thousandths) => thousandths / 1000), +); diff --git a/packages/binden/test/property/headers.test.ts b/packages/binden/test/property/headers.test.ts new file mode 100644 index 00000000..861b81a3 --- /dev/null +++ b/packages/binden/test/property/headers.test.ts @@ -0,0 +1,508 @@ +import { deepStrictEqual, ok } from "node:assert/strict"; +import { suite, test } from "node:test"; +import fc from "fast-check"; + +import { + Accept, + AcceptCharset, + AcceptEncoding, + AcceptLanguage, + Authorization, + ContentEncoding, + ContentRange, + ContentType, + Cookie, + Forwarded, + type IAcceptEncodings, + type IAuthenticationType, + type IEncodings, + IfModifiedSince, + Range, +} from "../../index.js"; +import { quote, split, unquote } from "../../src/headers/list.js"; +import { anyString, check, fieldValueChar, qvalue, token } from "./check.js"; + +/** The characters and fragments of the syntax of the headers */ +const syntax: readonly string[] = [ + ",", + ", ", + ";", + "; ", + "=", + '"', + "\\", + " ", + "\t", + "/", + "*", + "-", + ":", + "[", + "]", + "_", + "q=", + "q=0.5", + "Q=1", + "q=1.5", + "0", + "1", + "9", + "a", + "Z", + "text/html", + "*/*", + "utf-8", + "en-US", + "gzip", + "X-GZIP", + "br", + "Basic", + "bytes", + "bytes=", + "for=", + "proto=", + "host=", + "unknown", + "GMT", + "Sun, ", + "\xA0", + "\xFF", + "\x7F", + "\0", + "\r\n", + "\u0100", + "\u2028", +]; + +/** A value as a list: empty for `null` */ +function present(value: T | null): T[] { + return value === null ? [] : [value]; +} + +/** A string of field-value characters */ +function text(minLength = 0): fc.Arbitrary { + return fc.string({ unit: fieldValueChar, minLength, maxLength: 8 }); +} + +/** A header class: how its field values are parsed and serialized */ +interface IHeader { + /** Every element of a field value (`[]` for none) */ + parse(input: string): T[]; + /** What an element is made of, as given to its constructor */ + fields(element: T): F; + /** An element of its fields */ + create(fields: F): T; + /** A field value of one element */ + serialize(element: T): string; + /** Valid fields of an element */ + readonly valid: fc.Arbitrary; + /** The fields of the element of a field value of valid fields (as parsed) */ + parsed(fields: F): F; +} + +/** + * Field values of valid elements, alone, in lists, or with one fragment of + * the syntax inserted anywhere + */ +function inputs(header: IHeader): fc.Arbitrary { + const valid = header.valid.map((fields) => + header.serialize(header.create(fields)), + ); + return fc.oneof( + anyString(syntax), + valid, + fc.array(valid, { maxLength: 3 }).map((values) => values.join(", ")), + fc + .tuple(valid, fc.nat(), fc.constantFrom(...syntax)) + .map(([value, at, fragment]) => { + const index = at % (value.length + 1); + return `${value.slice(0, index)}${fragment}${value.slice(index)}`; + }), + ); +} + +/** The properties of the parser and the serialization of a header class */ +function properties(name: string, header: IHeader): void { + suite(name, () => { + test("parses any field value, into elements that its constructor accepts and that are parsed back as they are", () => { + check( + fc.property(inputs(header), (input) => { + for (const element of header.parse(input)) { + const fields = header.fields(element); + deepStrictEqual(header.fields(header.create(fields)), fields); + deepStrictEqual( + header + .parse(header.serialize(element)) + .map((e) => header.fields(e)), + [header.parsed(fields)], + ); + } + }), + ); + }); + + test("serializes valid elements into field values that are parsed back", () => { + check( + fc.property(header.valid, (fields) => { + const element = header.create(fields); + deepStrictEqual( + header + .parse(header.serialize(element)) + .map((e) => header.fields(e)), + [header.parsed(header.fields(element))], + ); + }), + ); + }); + }); +} + +/** The elements of a list header are the same in one field line or several */ +function lines( + parse: (input: string[] | string) => { toString(): string }[], +): void { + check( + fc.property(fc.array(anyString(syntax), { maxLength: 3 }), (values) => { + deepStrictEqual( + parse(values).map(String), + parse(values.join(",")).map(String), + ); + }), + ); +} + +/** Negotiation picks one of the offers, or `false` */ +function negotiates( + negotiate: (offers: readonly string[], input?: string) => string | false, + offer: fc.Arbitrary, +): void { + check( + fc.property( + fc.array(offer, { maxLength: 4 }), + fc.option(anyString(syntax)), + (offers, input) => { + const preferred = + input === null ? negotiate(offers) : negotiate(offers, input); + ok(preferred === false || offers.includes(preferred)); + if (input === null) { + deepStrictEqual(preferred, offers.at(0) ?? false); + } + }, + ), + ); +} + +/** A media range: `*\/*`, `type/*` or `type/subtype` */ +const mediaRange = fc.oneof( + fc.constant("*/*"), + token.filter((type) => type !== "*").map((type) => `${type}/*`), + fc + .tuple(token, token) + .filter(([type]) => type !== "*") + .map(([type, subtype]) => `${type}/${subtype}`), +); + +/** A language tag (RFC 4647, section 2.1) */ +const languageTag = fc.stringMatching( + /^[A-Za-z]{1,8}(?:-[A-Za-z\d]{1,8}){0,2}$/u, +); + +/** The cookie-octets (RFC 6265, section 4.1.1) */ +const cookieOctet = fc.constantFrom( + ..."!#$%&'()*+-./0123456789:<=>?@ABCXYZ[]^_`abcxyz{|}~".split(""), +); + +const knownCodings: readonly IEncodings[] = [ + "br", + "compress", + "deflate", + "gzip", + "x-gzip", + "zstd", +]; + +const schemes: readonly IAuthenticationType[] = [ + "AWS4-HMAC-SHA256", + "Basic", + "Bearer", + "Digest", + "HOBA", + "Mutual", + "OAuth", + "SCRAM-SHA-1", + "SCRAM-SHA-256", + "vapid", +]; + +/** A safe integer position */ +const position = fc.maxSafeNat(); + +/** Parameters by name: tokens (other than `q`) and field values */ +const parameters = fc.dictionary( + token.filter((name) => name.toLowerCase() !== "q"), + text(), + { maxKeys: 3 }, +); + +suite("Headers (properties)", () => { + properties("Accept", { + parse: (input) => Accept.fromString(input), + fields: ({ type, parameters: all, q_value }) => ({ + type, + parameters: all, + q_value, + }), + create: (fields) => new Accept(fields), + serialize: (element) => element.toString(), + valid: fc.record({ type: mediaRange, parameters, q_value: qvalue }), + // The media range in lowercase + parsed: (fields) => ({ ...fields, type: fields.type.toLowerCase() }), + }); + + properties("Accept-Charset", { + parse: (input) => AcceptCharset.fromString(input), + fields: ({ charset, q_value }) => ({ charset, q_value }), + create: (fields) => new AcceptCharset(fields), + serialize: (element) => element.toString(), + valid: fc.record({ charset: token, q_value: qvalue }), + parsed: (fields) => fields, + }); + + properties("Accept-Encoding", { + parse: (input) => AcceptEncoding.fromString(input), + fields: ({ encoding, q_value }) => ({ encoding, q_value }), + create: (fields) => new AcceptEncoding(fields), + serialize: (element) => element.toString(), + valid: fc.record({ + encoding: fc.constantFrom( + ...knownCodings, + "*", + "identity", + ), + q_value: qvalue, + }), + parsed: (fields) => fields, + }); + + properties("Accept-Language", { + parse: (input) => AcceptLanguage.fromString(input), + fields: ({ language, q_value }) => ({ language, q_value }), + create: (fields) => new AcceptLanguage(fields), + serialize: (element) => element.toString(), + valid: fc.record({ + language: fc.oneof(fc.constant("*"), languageTag), + q_value: qvalue, + }), + parsed: (fields) => fields, + }); + + properties("Authorization", { + parse: (input) => present(Authorization.fromString(input)), + fields: ({ type, credentials }) => ({ type, credentials }), + create: (fields) => new Authorization(fields), + serialize: (element) => element.toString(), + valid: fc.record({ + type: fc.constantFrom(...schemes), + credentials: fc.option(text(1).filter((value) => value === value.trim())), + }), + parsed: (fields) => fields, + }); + + properties("Content-Encoding", { + parse: (input) => ContentEncoding.fromString(input) ?? [], + fields: ({ encoding }) => ({ encoding }), + create: (fields) => new ContentEncoding(fields), + serialize: (element) => element.toString(), + valid: fc.record({ encoding: fc.constantFrom(...knownCodings) }), + parsed: (fields) => fields, + }); + + properties("Content-Range", { + parse: (input) => ContentRange.fromString(input), + fields: ({ start, end, size }) => ({ start, end, size }), + create: (fields) => new ContentRange(fields), + serialize: (element) => element.toString(), + valid: fc.oneof( + fc + .tuple(position, position, position) + .map((numbers) => numbers.toSorted((a, b) => a - b)) + .filter(([, end, size]) => end < size) + .map(([start, end, size]) => ({ start, end, size })), + fc + .tuple(position, position) + .map(([a, b]) => ({ start: Math.min(a, b), end: Math.max(a, b) })) + .map(({ start, end }) => ({ start, end, size: "*" as const })), + // An unsatisfied range has a size (`bytes *\/*` is not a content range) + position.map((size) => ({ start: null, end: null, size })), + ), + parsed: (fields) => fields, + }); + + properties("Content-Type", { + parse: (input) => present(ContentType.fromString(input)), + fields: ({ type, charset, boundary, parameters: all }) => ({ + type, + charset, + boundary, + parameters: all, + }), + create: (fields) => new ContentType(fields), + serialize: (element) => element.toString(), + valid: fc.record({ + // In lowercase: the type of `multipart/form-data` is compared as it is + type: fc.oneof( + fc.constantFrom("multipart/form-data", "text/plain"), + fc + .tuple(token, token) + .map(([type, subtype]) => `${type}/${subtype}`.toLowerCase()), + ), + charset: fc.option(text()), + boundary: text(1), + parameters, + }), + // The charset in lowercase + parsed: (fields) => { + const charset = fields.charset?.toLowerCase() ?? null; + return { + ...fields, + charset, + parameters: + charset === null + ? fields.parameters + : { ...fields.parameters, charset }, + }; + }, + }); + + properties("Cookie", { + parse: (input) => Cookie.fromString(input), + fields: ({ key, value }) => ({ key, value }), + create: (fields) => new Cookie(fields), + serialize: ({ key, value }) => `${key}=${value}`, + valid: fc.record({ + key: token, + value: fc + .tuple(fc.string({ unit: cookieOctet, maxLength: 8 }), fc.boolean()) + .map(([value, quoted]) => (quoted ? `"${value}"` : value)), + }), + // Without the double quotes of the value + parsed: ({ key, value }) => ({ + key, + value: /^".*"$/su.test(value) ? value.slice(1, -1) : value, + }), + }); + + properties("Forwarded", { + parse: (input) => Forwarded.fromString(input), + fields: (element) => ({ + for: element.for, + by: element.by, + host: element.host, + proto: element.proto, + secret: element.secret, + }), + create: (fields) => new Forwarded(fields), + serialize: (element) => element.toString(), + valid: fc.record({ + for: text(), + by: fc.option(text()), + host: fc.option(text()), + proto: fc.option(text()), + secret: fc.option(text()), + }), + parsed: (fields) => fields, + }); + + properties("If-Modified-Since", { + parse: (input) => present(IfModifiedSince.fromString(input)), + fields: ({ date }) => ({ date }), + create: (fields) => new IfModifiedSince(fields), + serialize: (element) => element.toString(), + // The years before 100 are not parsed: `Date.UTC()` reads them as 19xx + valid: fc + .date({ + min: new Date("0100-01-01T00:00:00Z"), + max: new Date("9999-12-31T23:59:59Z"), + noInvalidDate: true, + }) + .map((date) => ({ + date: new Date(date.getTime() - (date.getTime() % 1000)), + })), + parsed: (fields) => fields, + }); + + properties("Range", { + parse: (input) => Range.fromString(input), + fields: ({ start, end }) => ({ start, end }), + create: (fields) => new Range(fields), + serialize: (element) => element.toString(), + valid: fc.oneof( + position.map((start) => ({ start, end: null })), + position.map((end) => ({ start: null, end })), + fc + .tuple(position, position) + .map(([a, b]) => ({ start: Math.min(a, b), end: Math.max(a, b) })), + ), + parsed: (fields) => fields, + }); + + test("the lists of weighted values are the same in one field line or several", () => { + lines((input) => Accept.fromString(input)); + lines((input) => AcceptCharset.fromString(input)); + lines((input) => AcceptEncoding.fromString(input)); + lines((input) => AcceptLanguage.fromString(input)); + }); + + test("a negotiation picks one of the offers, or none", () => { + negotiates( + (offers, input) => Accept.negotiate(offers, input), + fc + .tuple(token, token) + .filter(([type, subtype]) => type !== "*" && subtype !== "*") + .map(([type, subtype]) => `${type}/${subtype}`), + ); + negotiates( + (offers, input) => AcceptCharset.negotiate(offers, input), + token.filter((charset) => charset !== "*"), + ); + negotiates( + (offers, input) => AcceptEncoding.negotiate(offers, input), + token.filter((coding) => coding !== "*"), + ); + negotiates( + (offers, input) => AcceptLanguage.negotiate(offers, input), + languageTag, + ); + }); + + test("Content-Encoding lists the codings in the order to remove them", () => { + check( + fc.property( + fc.array(fc.constantFrom(...knownCodings), { maxLength: 4 }), + (codings) => { + deepStrictEqual( + ContentEncoding.fromString(codings.join(", "))?.map(String), + codings.toReversed(), + ); + }, + ), + ); + }); + + test("a quoted string is unquoted as it was, and never split", () => { + check( + fc.property( + anyString(syntax), + anyString(syntax), + fc.constantFrom(",", ";"), + (a, b, separator) => { + deepStrictEqual(unquote(quote(a)), a); + deepStrictEqual( + split(`${quote(a)}${separator}${quote(b)}`, separator), + [quote(a), quote(b)], + ); + }, + ), + ); + }); +}); From 43690b2cbfc4999e816917c569808fa6e38ef45a Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 03:10:40 +0300 Subject: [PATCH 193/675] test: check request-targets and cookies with properties in binden `request.URL` is a URL without dot-segments or a 400 for any request-target, keeps every segment of the path of the request-targets that Node.js delivers, and refuses an encoded slash or backslash unless `encoded_slashes` allows it (then it is like any other percent-encoding). A cookie is refused with a TypeError, or serialized as a valid field value of one line whose `;` separate its known attributes only (also through `clearCookie()` and `writeHead()`), and the cookies of a `Cookie` header are parsed back. Co-Authored-By: Claude Opus 5.5 --- packages/binden/test/property/cookies.test.ts | 205 ++++++++++++++++++ .../test/property/request-target.test.ts | 178 +++++++++++++++ 2 files changed, 383 insertions(+) create mode 100644 packages/binden/test/property/cookies.test.ts create mode 100644 packages/binden/test/property/request-target.test.ts diff --git a/packages/binden/test/property/cookies.test.ts b/packages/binden/test/property/cookies.test.ts new file mode 100644 index 00000000..7d187b66 --- /dev/null +++ b/packages/binden/test/property/cookies.test.ts @@ -0,0 +1,205 @@ +import { deepStrictEqual, ok } from "node:assert/strict"; +import { validateHeaderValue } from "node:http"; +import { Socket } from "node:net"; +import { suite, test } from "node:test"; +import fc from "fast-check"; + +import { + BindenRequest, + BindenResponse, + Cookie, + type ICookie, +} from "../../index.js"; +import { anyString, check, token } from "./check.js"; + +/** Characters that could end a cookie, its attributes or its field line */ +const injections: readonly string[] = [ + ";", + "; ", + "; Domain=evil.test", + "; Path=/", + "; Secure", + "=", + ",", + " ", + "\t", + "\r", + "\n", + "\r\nSet-Cookie: a=b", + "\0", + "\x7F", + '"', + "\\", + "\xE9", + "\u2028", + "a", + "Z", + "0", + ".", + "-", + "/", +]; + +/** A string that may try to inject attributes or field lines */ +const hostile = fc.oneof( + anyString(injections, 12), + { arbitrary: token, weight: 2 }, + fc + .tuple(token, fc.constantFrom(...injections), token) + .map((parts) => parts.join("")), +); + +/** The attributes of a `Set-Cookie` value, in their order */ +const attribute = + /^(?:Max-Age=-?\d+|Expires=[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} -?\d{4,6} \d{2}:\d{2}:\d{2} GMT|Domain=[^;]+|Path=[^;]*|Secure|HttpOnly|SameSite=(?:Lax|None|Strict)|Partitioned)$/u; + +/** The attributes of the cookies, any of them maybe hostile */ +const cookies = fc.record( + { + key: hostile, + value: hostile, + expires: fc.oneof(fc.constant(null), fc.date()), + max_age: fc.oneof( + fc.constant(null), + fc.integer({ min: -1e6, max: 1e9 }), + fc.double(), + ), + domain: fc.oneof(fc.constant(null), fc.domain(), hostile), + path: fc.oneof(fc.constant(null), fc.webPath(), hostile), + secure: fc.boolean(), + http_only: fc.boolean(), + same_site: fc.constantFrom<"Lax" | "None" | "Strict">( + "Lax", + "None", + "Strict", + ), + partitioned: fc.boolean(), + }, + { requiredKeys: ["key", "value"] }, +); + +/** The cookie, or the error of its constructor */ +function create(input: ICookie): unknown { + try { + return new Cookie(input); + } catch (error: unknown) { + return error; + } +} + +/** Checks that a `Set-Cookie` value has the cookie and its attributes only */ +function checkSetCookie(cookie: Cookie): void { + const value = cookie.toString(); + // A valid field value of visible ASCII and spaces, on one line + ok(/^[\x20-\x7E]*$/u.test(value), value); + validateHeaderValue("Set-Cookie", value); + const [pair = "", ...attributes] = value.split("; "); + deepStrictEqual(pair, `${cookie.key}=${cookie.value}`); + // Every `;` separates an attribute + deepStrictEqual(value.split(";").length, attributes.length + 1); + for (const item of attributes) { + ok(attribute.test(item), item); + } + deepStrictEqual( + attributes.filter((item) => item.startsWith("Domain=")), + cookie.domain === null ? [] : [`Domain=${cookie.domain}`], + ); + deepStrictEqual( + attributes.filter((item) => item.startsWith("Path=")), + cookie.path === null ? [] : [`Path=${cookie.path}`], + ); +} + +suite("Cookies (properties)", () => { + test("a cookie is refused (`TypeError`), or serialized without injecting attributes or field lines", () => { + check( + fc.property(cookies, (input) => { + const cookie = create(input); + if (cookie instanceof Cookie) { + checkSetCookie(cookie); + } else { + ok(cookie instanceof TypeError, String(cookie)); + } + }), + 1000, + ); + }); + + test("a cookie that the `Cookie` header carries is parsed back as it is", () => { + check( + fc.property( + fc.array(fc.tuple(hostile, hostile), { maxLength: 4 }), + (pairs) => { + const valid = pairs.flatMap(([key, value]) => { + const cookie = create({ key, value }); + return cookie instanceof Cookie ? [cookie] : []; + }); + const header = valid.map(({ key, value }) => `${key}=${value}`); + const parsed = Cookie.fromString(header.join("; ")); + for (const cookie of parsed) { + const same = valid.find(({ key }) => key === cookie.key); + ok(same); + deepStrictEqual( + cookie.value, + /^".*"$/su.test(same.value) + ? same.value.slice(1, -1) + : same.value, + ); + } + deepStrictEqual( + parsed.map(({ key }) => key), + [...new Set(valid.map(({ key }) => key))], + ); + }, + ), + 500, + ); + }); + + test("`clearCookie()` refuses an attribute (`TypeError`), or sends a valid `Set-Cookie`", () => { + check( + fc.property( + hostile, + fc.record( + { + domain: fc.oneof(fc.constant(null), fc.domain(), hostile), + path: fc.oneof(fc.constant(null), fc.webPath(), hostile), + secure: fc.boolean(), + same_site: fc.constantFrom<"Lax" | "None" | "Strict">( + "Lax", + "None", + "Strict", + ), + partitioned: fc.boolean(), + }, + { requiredKeys: [] }, + ), + (name, attributes) => { + const socket = new Socket(); + try { + const response = new BindenResponse(new BindenRequest(socket)); + try { + response.clearCookie(name, attributes); + } catch (error: unknown) { + ok(error instanceof TypeError, String(error)); + return; + } + const [cookie] = response.cookies; + ok(cookie instanceof Cookie); + checkSetCookie(cookie); + deepStrictEqual(cookie.value, ""); + deepStrictEqual(cookie.max_age, 0); + // The headers are sent with it, whatever the attributes + response.writeHead(200); + deepStrictEqual(response.getHeader("Set-Cookie"), [ + cookie.toString(), + ]); + } finally { + socket.destroy(); + } + }, + ), + 500, + ); + }); +}); diff --git a/packages/binden/test/property/request-target.test.ts b/packages/binden/test/property/request-target.test.ts new file mode 100644 index 00000000..a9dafb0f --- /dev/null +++ b/packages/binden/test/property/request-target.test.ts @@ -0,0 +1,178 @@ +import { deepStrictEqual, ok } from "node:assert/strict"; +import { Socket } from "node:net"; +import { suite, test } from "node:test"; +import fc from "fast-check"; + +import { + BindenError, + BindenRequest, + type IRequestOptions, + validateRequestOptions, +} from "../../index.js"; +import { check, visible } from "./check.js"; + +/** The URL of a request-target (with `Host: origin.test`), or its error */ +function urlOf( + target: string, + { method = "GET", encoded_slashes = false } = {}, +): unknown { + class Options extends BindenRequest { + // eslint-disable-next-line class-methods-use-this -- a fixture + protected override get options(): IRequestOptions { + return validateRequestOptions({ encoded_slashes }); + } + } + const socket = new Socket(); + try { + const request = new Options(socket); + request.url = target; + request.method = method; + request.headers = { host: "origin.test" }; + request.headersDistinct = { host: ["origin.test"] }; + return request.URL; + } catch (error: unknown) { + return error; + } finally { + socket.destroy(); + } +} + +/** A dot-segment, maybe percent-encoded (RFC 3986, section 3.3) */ +const dotSegment = /^(?:\.|%2e){1,2}$/iu; + +/** + * A segment of a path in a canonical form: its percent-encodings in uppercase, + * and every other character but the unreserved ones and the sub-delimiters + * percent-encoded, as the URL parser may do + */ +function canonical(segment: string): string { + return segment + .replaceAll(/%[\da-f]{2}/giu, (encoded) => encoded.toUpperCase()) + .replaceAll( + /[^\w\-.~!$&'()*+,;=:@%]/gu, + (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} + +/** The fragments of the path of a request-target */ +const fragments: readonly string[] = [ + "/", + "//", + ".", + "..", + "%2e", + "%2E", + ".%2e", + "%2e.", + "%2f", + "%2F", + "%5c", + "%5C", + "%252e", + "%25", + "%", + "%z", + "%41", + "%ff", + "?", + "#", + "\\", + "a", + "B", + "~", +]; + +/** + * The path and query of a request-target as Node.js delivers it: visible + * ASCII only (its parser refuses control characters, spaces and other bytes) + */ +const target = fc + .array(fc.oneof(fc.constantFrom(...fragments), fc.constantFrom(...visible)), { + maxLength: 16, + }) + .map((parts) => `/${parts.join("")}`); + +/** A request-target with any character, or in another form */ +const anyTarget = fc.oneof( + target, + fc.string({ unit: "binary-ascii", maxLength: 20 }), + fc + .tuple( + fc.constantFrom("http://", "HTTPS://", "ftp://", "//", "http:///"), + fc.constantFrom("origin.test", "a:1", "user@origin.test", "[::1]", ""), + target, + ) + .map((parts) => parts.join("")), + fc + .tuple(target, fc.constantFrom("\t", "\n", "\r", "\0", " ", "\x7F"), target) + .map(([a, control, b]) => `${a}${control}${b.slice(1)}`), +); + +suite("Request-target (properties)", () => { + test("`URL` is a URL without dot-segments, or a `400 Bad Request`", () => { + check( + fc.property(anyTarget, fc.boolean(), (input, encoded_slashes) => { + const url = urlOf(input, { encoded_slashes }); + if (url instanceof URL) { + ok(!url.pathname.split("/").some((part) => dotSegment.test(part))); + } else { + ok(url instanceof BindenError, String(url)); + deepStrictEqual(url.status, 400); + } + }), + 500, + ); + }); + + test("`URL` keeps every segment of the path of an origin-form request-target", () => { + check( + fc.property(target, fc.boolean(), (input, encoded_slashes) => { + const url = urlOf(input, { encoded_slashes }); + if (url instanceof URL) { + const [path = ""] = input.split("?"); + deepStrictEqual( + url.pathname.split("/").map((part) => canonical(part)), + path.split("/").map((part) => canonical(part)), + ); + deepStrictEqual(url.host, "origin.test"); + } + }), + 500, + ); + }); + + test("`URL` refuses an encoded slash or backslash in the path, unless `encoded_slashes`", () => { + const encoded = /%(?:2f|5c)/iu; + check( + fc.property(target, (input) => { + const [path = "", ...query] = input.split("?"); + const url = urlOf(input); + ok(!(url instanceof URL) || !encoded.test(url.pathname)); + // Allowed, they are as any other percent-encoding + const neutral = [ + path.replaceAll(/%(?:2f|5c)/giu, "%41"), + ...query, + ].join("?"); + deepStrictEqual( + urlOf(input, { encoded_slashes: true }) instanceof URL, + urlOf(neutral) instanceof URL, + ); + }), + 500, + ); + }); + + test("`OPTIONS *` is the only request-target of the asterisk-form", () => { + ok(urlOf("*", { method: "OPTIONS" }) instanceof URL); + check( + fc.property( + fc.constantFrom("GET", "HEAD", "POST", "PUT", "DELETE"), + (method) => { + const error = urlOf("*", { method }); + ok(error instanceof BindenError && error.status === 400); + }, + ), + 20, + ); + }); +}); From 9ef9a80e39acdadd4210ee8de6299aae84b135c4 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 03:11:19 +0300 Subject: [PATCH 194/675] test: check the trust walk of the proxies and the route matcher with properties in binden The client, the trusted proxies, the protocol and the host of a request behind generated chains of Forwarded and X-Forwarded-For elements (valid, unknown, obfuscated or malformed) are those of a model of the trust walk, whose trust function sees every hop once; no address left of a malformed element is ever the client or a trusted proxy. `route()` and `mount()` accept a pattern if and only if its grammar does, a route or a mount prefix matches a path if and only if a reference matcher written apart does (with the same parameters, or a 400), and every parameter is decoded exactly once. Co-Authored-By: Claude Opus 5.5 --- packages/binden/test/property/proxy.test.ts | 359 ++++++++++++++++ packages/binden/test/property/route.test.ts | 430 ++++++++++++++++++++ 2 files changed, 789 insertions(+) create mode 100644 packages/binden/test/property/proxy.test.ts create mode 100644 packages/binden/test/property/route.test.ts diff --git a/packages/binden/test/property/proxy.test.ts b/packages/binden/test/property/proxy.test.ts new file mode 100644 index 00000000..43655ebb --- /dev/null +++ b/packages/binden/test/property/proxy.test.ts @@ -0,0 +1,359 @@ +import { deepStrictEqual, ok } from "node:assert/strict"; +import type { IncomingHttpHeaders } from "node:http"; +import { Socket } from "node:net"; +import { suite, test } from "node:test"; +import fc from "fast-check"; + +import { + BindenRequest, + type IProxyHeaders, + type IRequestOptions, + type ITrustProxy, + validateRequestOptions, +} from "../../index.js"; +import { check } from "./check.js"; + +/** What an element of a proxy header is */ +type IKind = "invalid" | "obfuscated" | "unknown" | "v4" | "v6"; + +/** An element of a proxy header, and what it tells */ +interface IElement { + readonly kind: IKind; + /** The address of the client of the proxy (`null`: unknown or invalid) */ + readonly address: string | null; + /** The element of `Forwarded` */ + readonly forwarded: string; + /** The element of `X-Forwarded-For` */ + readonly x_forwarded: string; + /** The protocol and the host of the element of `Forwarded` */ + readonly proto: string | null; + readonly host: string | null; +} + +/** What the request tells about its client */ +interface IClient { + readonly ip: string | null; + readonly ips: readonly string[]; + readonly protocol: string; + readonly host: string | null; +} + +/** The address of the peer, and the `Host` of the request */ +const peer = "198.51.100.7"; +const origin = "origin.test"; + +/** Malformed elements of `Forwarded`, of which none has a `,` */ +const invalidForwarded: readonly string[] = [ + "for=300.1.1.1", + 'for="[192.0.2.1]"', + "for=192.0.2.1;for=192.0.2.2", + "for", + "for=a b", + "for=192.0.2.1;proto=1http", + 'for=192.0.2.1;host="a/b"', + 'for=192.0.2.1;host="user@host.test"', + 'for="192.0.2.1:99999x"', +]; + +/** Malformed elements of `X-Forwarded-For` */ +const invalidXForwarded: readonly string[] = [ + "300.1.1.1", + "[192.0.2.1]", + "not-an-ip", + "192.0.2.1:x", + "::ffff:999.1.1.1", + "[::1", +]; + +/** An element at a position (from 0, left to right): its address is unique */ +function element(index: number): fc.Arbitrary { + const v4 = `192.0.2.${index + 1}`; + const v6 = `2001:db8::${(index + 1).toString(16)}`; + const proto = fc.constantFrom(null, "http", "https", "HTTPS", "ws"); + const host = fc.constantFrom(null, `h${index}.test`, `h${index}.test:8443`); + /** A valid element of `Forwarded`, with its `proto` and `host` */ + const forwarded = ( + kind: IKind, + address: string | null, + nodes: readonly string[], + x_forwarded: readonly string[], + ): fc.Arbitrary => + fc + .record({ + node: fc.constantFrom(...nodes), + xff: fc.constantFrom(...x_forwarded), + proto, + host, + }) + .map(({ node, xff, proto: p, host: h }) => ({ + kind, + address, + // Without `for`, the client is unknown (an empty element is none) + forwarded: + [ + ...(node === "" ? [] : [node]), + ...(p === null ? [] : [`proto=${p}`]), + ...(h === null ? [] : [`host=${h}`]), + ].join(";") || "for=unknown", + x_forwarded: xff, + proto: p?.toLowerCase() ?? null, + host: h, + })); + return fc.oneof( + forwarded("v4", v4, [`for=${v4}`, `for="${v4}:8080"`], [v4, `${v4}:8080`]), + forwarded( + "v6", + v6, + [`for="[${v6}]"`, `for="[${v6.toUpperCase()}]:443"`], + [v6, `[${v6}]:443`, `[${v6.toUpperCase()}]`], + ), + forwarded("unknown", null, ["for=unknown", "for=UNKNOWN", ""], ["unknown"]), + forwarded( + "obfuscated", + null, + ["for=_hidden", 'for="_x:_y"'], + ["_hidden", "_x.y"], + ), + fc + .tuple( + fc.constantFrom(...invalidForwarded), + fc.constantFrom(...invalidXForwarded), + ) + .map(([f, x]) => ({ + kind: "invalid" as const, + address: null, + forwarded: f, + x_forwarded: x, + proto: null, + host: null, + })), + ); +} + +/** A chain of up to 6 elements */ +const chain = fc + .integer({ min: 0, max: 6 }) + .chain((length) => + fc.tuple(...Array.from({ length }, (_, index) => element(index))), + ) + .map((elements): readonly IElement[] => elements); + +/** How the hops are trusted: by count, by address, or all or none of them */ +type ITrust = + | { readonly kind: "addresses"; readonly trusted: readonly string[] } + | { readonly kind: "all"; readonly value: boolean } + | { readonly kind: "count"; readonly value: number }; + +const trusts: fc.Arbitrary = fc.oneof( + fc.boolean().map((value) => ({ kind: "all" as const, value })), + fc.integer({ min: 0, max: 7 }).map((value) => ({ + kind: "count" as const, + value, + })), + fc + .subarray([ + peer, + ...Array.from({ length: 6 }, (_, i) => `192.0.2.${i + 1}`), + ...Array.from({ length: 6 }, (_, i) => `2001:db8::${i + 1}`), + ]) + .map((addresses) => ({ kind: "addresses" as const, trusted: addresses })), +); + +/** Whether a hop is trusted */ +function trusted(trust: ITrust, address: string, hop: number): boolean { + if (trust.kind === "all") { + return trust.value; + } else if (trust.kind === "count") { + return hop < trust.value; + } + return trust.trusted.includes(address); +} + +/** The field lines of `X-Forwarded-Proto` and `X-Forwarded-Host` */ +const xForwardedOrigin = fc.record({ + proto: fc.constantFrom( + [], + ["https"], + ["HTTP"], + ["https", "http"], + ["https,http"], + ["1bad"], + ), + host: fc.constantFrom( + [], + ["xf.test"], + ["xf.test:8080"], + ["a.test", "b.test"], + ["a/b"], + ), +}); + +/** The client that the request tells about (see `IClient`) */ +function client( + header: IProxyHeaders, + trust: ITrust, + elements: readonly IElement[], + fields: { proto: readonly string[]; host: readonly string[] }, + calls: [string, number][], +): IClient { + class Trusting extends BindenRequest { + // eslint-disable-next-line class-methods-use-this -- a fixture + protected override get options(): IRequestOptions { + const fn = (address: string, hop: number): boolean => { + calls.push([address, hop]); + return trusted(trust, address, hop); + }; + const value: ITrustProxy = trust.kind === "addresses" ? fn : trust.value; + return { + ...validateRequestOptions(), + trust_proxy: { trust: value, headers: header }, + }; + } + } + const socket = new Socket(); + Object.defineProperty(socket, "remoteAddress", { value: peer }); + try { + const request = new Trusting(socket); + request.url = "/"; + request.method = "GET"; + const lines: [string, string[]][] = [ + ["host", [origin]], + ["forwarded", elements.map((e) => e.forwarded)], + ["x-forwarded-for", elements.map((e) => e.x_forwarded)], + ["x-forwarded-proto", [...fields.proto]], + ["x-forwarded-host", [...fields.host]], + ]; + const headers: IncomingHttpHeaders = {}; + const distinct: NodeJS.Dict = {}; + for (const [name, values] of lines.filter(([, v]) => v.length > 0)) { + // As Node.js does: the field lines of a name joined, and also kept apart + headers[name] = values.join(", "); + distinct[name] = values; + } + request.headers = headers; + request.headersDistinct = distinct; + const { ip, ips, protocol, host } = request; + return { ip, ips, protocol, host }; + } finally { + socket.destroy(); + } +} + +/** What the trusted proxies tell (RFC 7239 and the contract of `trust_proxy`) */ +function model( + header: IProxyHeaders, + trust: ITrust, + elements: readonly IElement[], + fields: { proto: readonly string[]; host: readonly string[] }, +): IClient & { readonly calls: [string, number][] } { + const calls: [string, number][] = [[peer, 0]]; + const connection = { protocol: "http:", host: origin }; + if (!trusted(trust, peer, 0)) { + return { ip: peer, ips: [peer], ...connection, calls }; + } + // `X-Forwarded-Proto` and `-Host` of the trusted peer: one valid value each + const [proto = null] = fields.proto; + const [xHost = null] = fields.host; + const single = + fields.proto.length <= 1 && + fields.host.length <= 1 && + !(proto ?? "").includes(",") && + proto !== "1bad" && + xHost !== "a/b"; + let told = + header === "x-forwarded" && single + ? { proto: proto?.toLowerCase() ?? null, host: xHost } + : { proto: null, host: null }; + const reply = ( + ip: string | null, + ips: string[], + ): IClient & { calls: [string, number][] } => ({ + ip, + ips: ips.toReversed(), + protocol: + told.proto === "http" || told.proto === "https" + ? `${told.proto}:` + : "http:", + host: told.host ?? origin, + calls, + }); + const ips = [peer]; + for (let hop = 1; hop <= elements.length; hop += 1) { + const hopElement = elements[elements.length - hop]; + if (hopElement.kind === "invalid") { + told = { proto: null, host: null }; + return reply(null, ips); + } else if (header === "forwarded") { + told = { proto: hopElement.proto, host: hopElement.host }; + } + if (hopElement.address === null) { + return reply(null, ips); + } + ips.push(hopElement.address); + calls.push([hopElement.address, hop]); + if (!trusted(trust, hopElement.address, hop)) { + return reply(hopElement.address, ips); + } + } + return reply(ips.at(-1) ?? null, ips); +} + +suite("Trusted proxies (properties)", () => { + test("the client, the trusted proxies, the protocol and the host are those of the trust walk", () => { + check( + fc.property( + fc.constantFrom("forwarded", "x-forwarded"), + trusts, + chain, + xForwardedOrigin, + (header, trust, elements, fields) => { + const calls: [string, number][] = []; + const actual = client(header, trust, elements, fields, calls); + const { calls: expectedCalls, ...expected } = model( + header, + trust, + elements, + fields, + ); + deepStrictEqual(actual, expected); + if (trust.kind === "addresses") { + // The function sees every hop on the way once, from the peer + deepStrictEqual(calls, expectedCalls); + } + }, + ), + 1000, + ); + }); + + test("no address left of an invalid element is ever the client or a trusted proxy", () => { + check( + fc.property( + fc.constantFrom("forwarded", "x-forwarded"), + trusts, + chain, + (header, trust, elements) => { + const { ip, ips } = client( + header, + trust, + elements, + { proto: [], host: [] }, + [], + ); + const invalid = elements.findLastIndex((e) => e.kind === "invalid"); + const left = new Set( + elements.slice(0, invalid + 1).map((e) => e.address), + ); + ok(ip === null || !left.has(ip), String(ip)); + ok(ips.every((address) => !left.has(address))); + if (invalid >= 0 && ip !== null) { + // Then the walk has stopped before the invalid element + const index = elements.findIndex((e) => e.address === ip); + ok(ip === peer || index > invalid); + } + }, + ), + 1000, + ); + }); +}); diff --git a/packages/binden/test/property/route.test.ts b/packages/binden/test/property/route.test.ts new file mode 100644 index 00000000..b1e1b50c --- /dev/null +++ b/packages/binden/test/property/route.test.ts @@ -0,0 +1,430 @@ +import { deepStrictEqual, ok, throws } from "node:assert/strict"; +import { suite, test } from "node:test"; +import fc from "fast-check"; + +import { BindenError, type IParams, Stack } from "../../index.js"; +import { compile, matchPrefix, matchRoute } from "../../src/route.js"; +import { check } from "./check.js"; + +/** A segment of a pattern: a literal, a parameter, or the tail */ +type ISpec = + | { readonly literal: string } + | { readonly param: string } + | { readonly tail: string }; + +/** The fragments of the patterns (some of them invalid) */ +const patternFragments: readonly string[] = [ + "/", + ":", + "*", + "\\", + "\\:", + "\\*", + ":id", + "*rest", + ":_a1", + ":1", + "a", + "B", + "_", + "-", + ".", + "%", + "%41", +]; + +/** A pattern as registered: a `/` and fragments */ +const anyPattern = fc + .array(fc.constantFrom(...patternFragments), { maxLength: 8 }) + .map((parts) => `/${parts.join("")}`); + +/** + * Compiles a pattern by the grammar of `route()` and `mount()` (written + * apart from `compile()`): its segments, or `null` when it is invalid + */ +function grammar(pattern: string, kind: "mount" | "route"): ISpec[] | null { + if (!pattern.startsWith("/")) { + return null; + } + const raw = pattern.slice(1).split("/"); + if (kind === "mount" && raw.at(-1) === "") { + raw.pop(); + } + const names: string[] = []; + const specs: ISpec[] = []; + for (const [index, segment] of raw.entries()) { + const name = segment.slice(1); + if (segment.startsWith(":") || segment.startsWith("*")) { + const last = index === raw.length - 1; + if ( + !/^[A-Za-z_]\w*$/u.test(name) || + names.includes(name) || + (segment.startsWith("*") && (kind === "mount" || !last)) + ) { + return null; + } + names.push(name); + specs.push(segment.startsWith(":") ? { param: name } : { tail: name }); + } else { + // `\:` and `\*` are literal, and no other `\`, `:` or `*` may occur + const literal = segment.replaceAll(/\\(?[:*])/gu, "$"); + if (/[:*\\]/u.test(segment.replaceAll(/\\[:*]/gu, ""))) { + return null; + } + specs.push({ literal }); + } + } + return specs; +} + +/** A value of a segment, decoded once: `null` when it cannot be decoded */ +function decoded(segment: string): string | null { + try { + return decodeURIComponent(segment); + } catch { + return null; + } +} + +/** The options of the matching */ +interface IOptions { + readonly case_sensitive: boolean; + readonly strict: boolean; + readonly encoded_slashes: boolean; +} + +/** `400 Bad Request` */ +const BAD_REQUEST = "400"; + +/** + * Matches the segments of a path with the segments of a pattern (from the + * first one; `prefix` matches the leading ones only): the parameters, `null` + * for no match, or `400` for a matching path with a parameter that cannot be + * decoded (or that has an encoded slash, unless `encoded_slashes`) + */ +function reference( + specs: readonly ISpec[], + path: string, + options: IOptions, + prefix = false, +): IParams | typeof BAD_REQUEST | null { + let parts = path.slice(1).split("/"); + let segments = specs.filter((spec) => !("tail" in spec)); + const tail = specs.find((spec) => "tail" in spec); + if (!prefix && !options.strict) { + // One trailing `/` of the path or of the pattern may be missing + if (parts.length > 1 && parts.at(-1) === "") { + parts = parts.slice(0, -1); + } + const last = segments.at(-1); + if ( + !tail && + segments.length > 1 && + last && + "literal" in last && + last.literal === "" + ) { + segments = segments.slice(0, -1); + } + } + const fits = + prefix || tail + ? parts.length >= segments.length + : parts.length === segments.length; + if (!fits) { + return null; + } + const same = (part: string, literal: string): boolean => { + const value = decoded(part); + return ( + part === literal || + (value !== null && + (options.case_sensitive + ? value === literal + : value.toLowerCase() === literal.toLowerCase())) + ); + }; + for (const [index, segment] of segments.entries()) { + const part = parts[index] ?? ""; + if ("literal" in segment ? !same(part, segment.literal) : part === "") { + return null; + } + } + const value = (part: string): string | null => { + const once = decoded(part); + return once === null || (!options.encoded_slashes && /[/\\]/u.test(once)) + ? null + : once; + }; + const params: [string, string][] = []; + for (const [index, segment] of segments.entries()) { + if ("param" in segment) { + const once = value(parts[index] ?? ""); + if (once === null) { + return BAD_REQUEST; + } + params.push([segment.param, once]); + } + } + if (tail && "tail" in tail) { + const rest = parts.slice(segments.length).map((part) => value(part)); + if (rest.includes(null)) { + return BAD_REQUEST; + } + params.push([tail.tail, rest.join("/")]); + } + return Object.fromEntries(params); +} + +/** The result of a matcher: its value, or `400` for a `BindenError` (400) */ +function outcome(match: () => T): T | typeof BAD_REQUEST { + try { + return match(); + } catch (error: unknown) { + ok(error instanceof BindenError && error.status === 400, String(error)); + return BAD_REQUEST; + } +} + +/** A valid pattern of a kind, with its segments */ +function validPattern( + kind: "mount" | "route", +): fc.Arbitrary<{ pattern: string; specs: ISpec[] }> { + return anyPattern + .map((pattern) => ({ pattern, specs: grammar(pattern, kind) })) + .filter( + (compiled): compiled is { pattern: string; specs: ISpec[] } => + compiled.specs !== null, + ); +} + +const options = fc.record({ + case_sensitive: fc.boolean(), + strict: fc.boolean(), + encoded_slashes: fc.boolean(), +}); + +/** A value of a parameter: any characters, `%` and encoded ones among them */ +const paramValue = fc.string({ + unit: fc.constantFrom( + "a", + "Z", + "%", + "2", + "5", + "4", + "1", + "F", + "/", + "\\", + " ", + "é", + "€", + "?", + "#", + ":", + ), + minLength: 1, + maxLength: 8, +}); + +/** The fragments of the paths */ +const pathFragments: readonly string[] = [ + "/", + "a", + "A", + "B", + "_", + "-", + ".", + "%", + "%41", + "%61", + "%2F", + "%5c", + "%25", + "%2541", + "%E2%82%AC", + "%E2%82", + "%zz", + ":", + "*", + "id", +]; + +/** A path: a `/` and fragments */ +const anyPath = fc + .array(fc.constantFrom(...pathFragments), { maxLength: 10 }) + .map((parts) => `/${parts.join("")}`); + +/** A segment of a path: any fragments */ +const anySegment = fc + .array(fc.constantFrom(...pathFragments.filter((f) => f !== "/")), { + maxLength: 3, + }) + .map((parts) => parts.join("")); + +/** + * A path made for the segments of a pattern, which they mostly match: a + * literal as it is, encoded or in another case, a parameter of any value, + * maybe with segments added or removed at its end + */ +function pathFor(specs: readonly ISpec[]): fc.Arbitrary { + const segment = (spec: ISpec): fc.Arbitrary => { + if ("literal" in spec) { + return fc.oneof( + { arbitrary: fc.constant(spec.literal), weight: 3 }, + fc.constant(encodeURIComponent(spec.literal)), + fc.constant(spec.literal.toUpperCase()), + anySegment, + ); + } else if ("param" in spec) { + return fc.oneof( + { arbitrary: paramValue.map((v) => encodeURIComponent(v)), weight: 3 }, + anySegment, + ); + } + return fc + .array( + paramValue.map((v) => encodeURIComponent(v)), + { maxLength: 3 }, + ) + .map((values) => values.join("/")); + }; + return fc + .tuple( + fc.tuple(...specs.map((spec) => segment(spec))), + fc.constantFrom("", "", "/", "/a", "//"), + fc.nat(specs.length), + ) + .map(([parts, end, drop]) => { + const kept = + drop === specs.length ? parts : parts.slice(0, specs.length - drop); + return `/${kept.join("/")}${end}`; + }); +} + +/** A pattern of a kind with its segments, and paths for it */ +function withPath( + kind: "mount" | "route", +): fc.Arbitrary<[{ pattern: string; specs: ISpec[] }, string]> { + return validPattern(kind).chain((compiled) => + fc.tuple( + fc.constant(compiled), + fc.oneof(anyPath, { arbitrary: pathFor(compiled.specs), weight: 3 }), + ), + ); +} + +/** A middleware that does nothing */ +function noop(): void { + // Nothing +} + +suite("Routing (properties)", () => { + test("`route()` and `mount()` accept a pattern if and only if the grammar does", () => { + check( + fc.property(anyPattern, (pattern) => { + for (const kind of ["route", "mount"] as const) { + const stack = new Stack(); + const add = (): Stack => { + if (kind === "route") { + return stack.route(pattern, noop); + } + return stack.mount(pattern, noop); + }; + if (grammar(pattern, kind) === null) { + throws(add, TypeError); + } else { + add(); + deepStrictEqual(stack.stack.length, 1); + } + } + }), + 1000, + ); + }); + + test("a route matches a path if and only if the reference matcher does, with the same parameters", () => { + check( + fc.property( + withPath("route"), + options, + ([{ pattern, specs }, path], settings) => { + deepStrictEqual( + outcome(() => + matchRoute(compile(pattern, "route"), path, settings), + ), + reference(specs, path, settings), + ); + }, + ), + 1000, + ); + }); + + test("a mount prefix matches a path on a segment boundary if and only if the reference matcher does", () => { + check( + fc.property( + withPath("mount"), + options, + ([{ pattern, specs }, path], settings) => { + const match = outcome(() => + matchPrefix(compile(pattern, "mount"), path, settings), + ); + const expected = reference(specs, path, settings, true); + if (match === null || match === BAD_REQUEST) { + deepStrictEqual(match, expected); + return; + } + deepStrictEqual(match.params, expected); + // The matched prefix and the rest are the path + const { prefix, rest } = match; + ok(rest.startsWith("/")); + ok( + `${prefix}${rest}` === path || (rest === "/" && prefix === path), + `${prefix} ${rest}`, + ); + deepStrictEqual(prefix.split("/").length - 1, specs.length); + }, + ), + 1000, + ); + }); + + test("every parameter is decoded exactly once", () => { + check( + fc.property( + fc.array(paramValue, { minLength: 1, maxLength: 4 }), + fc.array(paramValue, { maxLength: 3 }), + fc.boolean(), + (values, rest, encoded_slashes) => { + const names = values.map((_, index) => `p${index}`); + const pattern = `/x/${names.map((name) => `:${name}`).join("/")}/*tail`; + const path = `/x/${[...values, ...rest].map((value) => encodeURIComponent(value)).join("/")}`; + const slashes = [...values, ...rest].some((value) => + /[/\\]/u.test(value), + ); + const expected = + slashes && !encoded_slashes + ? BAD_REQUEST + : { + ...Object.fromEntries( + names.map((name, index) => [name, values[index]]), + ), + tail: rest.join("/"), + }; + deepStrictEqual( + outcome(() => + matchRoute(compile(pattern, "route"), path, { + encoded_slashes, + }), + ), + expected, + ); + }, + ), + 500, + ); + }); +}); From e3743fdcb326df64787db421c8c954295fb4e9fe Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 03:12:22 +0300 Subject: [PATCH 195/675] build: add a benchmark of hello, JSON, route parameters, bodies and compression `npm run bench` builds the packages, forks a server of them and sends the requests of every scenario over keep-alive connections of node:http, then prints the requests per second and the p50 and p99 latencies. It has no dependencies and is not run in CI: its numbers compare versions on one machine. Co-Authored-By: Claude Opus 5.5 --- bench/README.md | 24 +++++++++ bench/index.mjs | 129 +++++++++++++++++++++++++++++++++++++++++++++++ bench/server.mjs | 48 ++++++++++++++++++ package.json | 1 + 4 files changed, 202 insertions(+) create mode 100644 bench/README.md create mode 100644 bench/index.mjs create mode 100644 bench/server.mjs diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 00000000..843d1ae0 --- /dev/null +++ b/bench/README.md @@ -0,0 +1,24 @@ +# Benchmarks + +A small benchmark of binden, without dependencies: `index.mjs` forks a server +(`server.mjs`, the built packages of the workspaces) and sends the requests of +every scenario over keep-alive connections of `node:http`, then prints the +requests per second and the latencies (p50 and p99). + +| Scenario | Request | +| ------------- | --------------------------------------------------------------------------------- | +| `hello` | `GET /hello`: `context.text()` | +| `json` | `GET /json`: `context.json()` | +| `params` | `GET /users/42/posts/hello%20world`: a `route()` with two parameters | +| `body` | `POST /echo` with 1 KiB of JSON: `@binden/body`, then `context.json()` | +| `compression` | `GET /large` (16 KiB of text, `Accept-Encoding: br, gzip`): `@binden/compression` | + +```bash +npm run bench +npm run bench -- --duration 10 --connections 64 --scenario json --scenario body +``` + +`npm run bench` builds the packages first. The client and the server share the +machine (the loopback interface): compare the results of one machine, one +Node.js version and one load, not absolute numbers. The benchmark is not run in +CI. diff --git a/bench/index.mjs b/bench/index.mjs new file mode 100644 index 00000000..3a8e9d8f --- /dev/null +++ b/bench/index.mjs @@ -0,0 +1,129 @@ +// A small benchmark of binden (see README.md): it forks the server, then +// sends the requests of every scenario over keep-alive connections for a +// while, and prints the requests per second and the latencies. +// +// npm run bench -- --duration 10 --connections 64 --scenario json +import { Agent, request } from "node:http"; +import { fork } from "node:child_process"; +import { once } from "node:events"; +import { parseArgs } from "node:util"; + +const { values } = parseArgs({ + options: { + duration: { type: "string", default: "5" }, + connections: { type: "string", default: "32" }, + scenario: { type: "string", multiple: true }, + }, +}); +const duration = Number(values.duration) * 1000; +const connections = Number(values.connections); +if (!(duration > 0) || !Number.isSafeInteger(connections) || connections < 1) { + throw new RangeError("`--duration` and `--connections` must be positive"); +} + +/** A JSON body of about 1 KiB */ +const body = JSON.stringify({ + items: Array.from({ length: 16 }, (_, id) => ({ id, name: `item ${id}` })), + text: "x".repeat(512), +}); + +/** The requests of the scenarios */ +const scenarios = [ + { name: "hello", method: "GET", path: "/hello" }, + { name: "json", method: "GET", path: "/json" }, + { name: "params", method: "GET", path: "/users/42/posts/hello%20world" }, + { + name: "body", + method: "POST", + path: "/echo", + headers: { "content-type": "application/json" }, + body, + }, + { + name: "compression", + method: "GET", + path: "/large", + headers: { "accept-encoding": "br, gzip" }, + }, +].filter(({ name }) => !values.scenario || values.scenario.includes(name)); + +/** Sends a request and reads its response: its status, or an error */ +function send(agent, port, { method, path, headers = {}, body: data }) { + return new Promise((resolve) => { + const req = request( + { agent, port, host: "127.0.0.1", method, path, headers }, + (res) => { + res.on("data", () => null); + res.on("end", () => resolve(res.statusCode)); + res.on("error", () => resolve(0)); + }, + ); + req.on("error", () => resolve(0)); + req.end(data); + }); +} + +/** Runs a scenario: the count of the requests, of the errors, and the latencies */ +async function run(port, scenario) { + const agent = new Agent({ keepAlive: true, maxSockets: connections }); + const latencies = []; + let errors = 0; + const deadline = performance.now() + duration; + const connection = async () => { + while (performance.now() < deadline) { + const start = performance.now(); + const status = await send(agent, port, scenario); + latencies.push(performance.now() - start); + if (status < 200 || status > 299) { + errors += 1; + } + } + }; + // Warm up, then measure + await send(agent, port, scenario); + const started = performance.now(); + await Promise.all(Array.from({ length: connections }, connection)); + const elapsed = (performance.now() - started) / 1000; + agent.destroy(); + latencies.sort((a, b) => a - b); + const at = (q) => + latencies[ + Math.min(latencies.length - 1, Math.floor(q * latencies.length)) + ] ?? 0; + return { + scenario: scenario.name, + "req/s": Math.round(latencies.length / elapsed), + "p50 ms": at(0.5).toFixed(2), + "p99 ms": at(0.99).toFixed(2), + errors, + }; +} + +const server = fork(new URL("server.mjs", import.meta.url), { + stdio: "inherit", +}); +try { + const [{ port }] = await once(server, "message"); + const results = []; + for (const scenario of scenarios) { + results.push(await run(port, scenario)); + } + const columns = Object.keys(results[0] ?? {}); + const rows = [ + columns, + ...results.map((result) => columns.map((c) => String(result[c]))), + ]; + const widths = columns.map((_, i) => + Math.max(...rows.map((row) => row[i].length)), + ); + process.stdout.write( + `Node.js ${process.version}, ${connections} connections, ${duration / 1000} s per scenario\n`, + ); + for (const row of rows) { + process.stdout.write( + `${row.map((cell, i) => cell.padStart(widths[i])).join(" ")}\n`, + ); + } +} finally { + server.disconnect(); +} diff --git a/bench/server.mjs b/bench/server.mjs new file mode 100644 index 00000000..505856d2 --- /dev/null +++ b/bench/server.mjs @@ -0,0 +1,48 @@ +// The server of the benchmark (see README.md): a binden application on an +// ephemeral port of the loopback interface, run in a child process so that +// the client does not take its CPU. It tells its port to the parent process. +import { Binden, Router, shutdown } from "binden"; +import { BodyParser } from "@binden/body"; +import { Compression } from "@binden/compression"; + +/** A text of 16 KiB, which compresses well */ +const large = "binden: a simple HTTP framework\n".repeat(512); + +const app = new Binden() + .route( + "/hello", + new Router().get((context) => context.text("Hello, World!")), + ) + .route( + "/json", + new Router().get((context) => + context.json({ hello: "world", list: [1, 2, 3], nested: { ok: true } }), + ), + ) + .route( + "/users/:user/posts/:post", + new Router().get((context) => context.json(context.params)), + ) + .route( + "/echo", + new BodyParser({ limit: 1024 * 1024, encoded_limit: 1024 * 1024 }), + new Router().post((context) => context.json(context.request.body)), + ) + .route( + "/large", + new Compression(), + new Router().get((context) => context.text(large)), + ); + +const server = app.createServer({ keepAliveTimeout: 60_000 }); + +server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + process.send({ port }); +}); + +process.on("disconnect", () => { + shutdown(server, { timeout: 1000 }).catch(() => { + process.exitCode = 1; + }); +}); diff --git a/package.json b/package.json index a4d94647..ceddf683 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "npm": ">=11.11.0" }, "scripts": { + "bench": "npm run build && node bench/index.mjs", "build": "npm run build --workspaces", "ci:all": "npm ci && npm audit signatures && npm audit --omit=dev --audit-level=high && npm run release:check && npm run prettier && npm run commitlint && npm run eslint && npm run test:tooling && npm run coverage && npm run pack:check && npm run consumer:check && npm run release:rehearsal && npm run docs:build", "commitlint": "commitlint --verbose --from=origin/main", From 0c06d3ce3144ae44c4857cadea2182dd967f4c75 Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 03:15:04 +0300 Subject: [PATCH 196/675] docs: add runnable examples of a production setup Each example is a small server that imports the roots of the packages only, and uses the values of the production guide: a deadline of 30 s, the timeouts of the Node.js server (headers 20 s, request 120 s, keep-alive 65 s) and a graceful shutdown on SIGTERM (20 s, cleanups 2 s). They cover a hello world, routes and mounts with parameters, JSON bodies with limits, uploads to the disk (kept with persist()) and to a stream, compression with server-sent events (a filter, and a flush after each event), the graceful shutdown of a request in progress, the trust of proxies by address for both Forwarded and X-Forwarded-*, and the security headers (CSP with a nonce, HSTS over HTTPS, nosniff, Referrer-Policy, COOP). A reader can run them as they are, and the checks that follow run them too. Co-Authored-By: Claude Opus 5.5 --- examples/body.ts | 75 ++++++++++++++++++++++++ examples/hello.ts | 36 ++++++++++++ examples/multipart.ts | 108 +++++++++++++++++++++++++++++++++++ examples/proxy.ts | 69 ++++++++++++++++++++++ examples/routing.ts | 68 ++++++++++++++++++++++ examples/security-headers.ts | 88 ++++++++++++++++++++++++++++ examples/shutdown.ts | 58 +++++++++++++++++++ examples/sse.ts | 82 ++++++++++++++++++++++++++ examples/tsconfig.json | 8 +++ 9 files changed, 592 insertions(+) create mode 100644 examples/body.ts create mode 100644 examples/hello.ts create mode 100644 examples/multipart.ts create mode 100644 examples/proxy.ts create mode 100644 examples/routing.ts create mode 100644 examples/security-headers.ts create mode 100644 examples/shutdown.ts create mode 100644 examples/sse.ts create mode 100644 examples/tsconfig.json diff --git a/examples/body.ts b/examples/body.ts new file mode 100644 index 00000000..2296cfd4 --- /dev/null +++ b/examples/body.ts @@ -0,0 +1,75 @@ +// JSON bodies with explicit limits: nothing limits a body by default, and a small +// compressed body may decode to gigabytes. +// +// PORT=3000 node --import tsx examples/body.ts +// curl -H "Content-Type: application/json" -d '{"name":"tea"}' http://localhost:3000/items +import { Binden, BindenError, type Context, Router, shutdown } from "binden"; +import type { AddressInfo } from "node:net"; +import { BodyParser } from "@binden/body"; + +// 1 MiB as received (`Content-Length`, or counted while it is read) and once +// decoded (`Content-Encoding: gzip`, `br`, `deflate`): a larger body gets `413` +const json = new BodyParser({ + limit: 1024 * 1024, + encoded_limit: 1024 * 1024, + type: ["application/json"], +}); + +interface IItem { + name: string; + quantity?: number | undefined; +} + +function isItem(value: unknown): value is IItem { + if (typeof value !== "object" || value === null) { + return false; + } + const { name, quantity } = value as Partial; + return ( + typeof name === "string" && + (typeof quantity === "undefined" || Number.isSafeInteger(quantity)) + ); +} + +const create = (context: Context): Promise => { + // The parser skips the other media types (without reading their body): the + // route requires its own + if (context.request.content_type?.type !== "application/json") { + throw new BindenError(415, { + expose: true, + message: "Send application/json", + }); + } + const { body } = context.request; + if (!isItem(body)) { + throw new BindenError(422, { expose: true, message: "Invalid item" }); + } + return context + .status(201) + .json({ name: body.name, quantity: body.quantity ?? 1 }); +}; + +export const app = new Binden({ timeout: 30_000 }).use( + "/items", + new Router().post(json, create), +); + +export const server = app.createServer({ + headersTimeout: 20_000, + requestTimeout: 120_000, + keepAliveTimeout: 65_000, +}); + +server.listen(Number(process.env.PORT ?? 3000), () => { + const { port } = server.address() as AddressInfo; + process.stdout.write(`Listening on http://localhost:${port}\n`); +}); + +process.once("SIGTERM", () => { + shutdown(server, { timeout: 20_000, cleanup_timeout: 2000 }).catch( + (error: unknown) => { + process.stderr.write(`${String(error)}\n`); + process.exitCode = 1; + }, + ); +}); diff --git a/examples/hello.ts b/examples/hello.ts new file mode 100644 index 00000000..a5d9176b --- /dev/null +++ b/examples/hello.ts @@ -0,0 +1,36 @@ +// The smallest production-shaped application: a deadline for every request, the +// timeouts of the Node.js server, and a graceful shutdown on SIGTERM. +// +// PORT=3000 node --import tsx examples/hello.ts +// curl http://localhost:3000/ +import { Binden, type Context, shutdown } from "binden"; +import type { AddressInfo } from "node:net"; + +export const app = new Binden({ timeout: 30_000 }).use( + "/", + (context: Context): Promise => context.text("Hello, World!"), +); + +export const server = app.createServer({ + // The headers of a request must arrive within 20 s, the whole request (its body + // included) within 120 s + headersTimeout: 20_000, + requestTimeout: 120_000, + // Longer than the idle timeout of a proxy in front (e.g. 60 s for an AWS ALB) + keepAliveTimeout: 65_000, +}); + +server.listen(Number(process.env.PORT ?? 3000), () => { + const { port } = server.address() as AddressInfo; + process.stdout.write(`Listening on http://localhost:${port}\n`); +}); + +// The requests in progress have 20 s to complete, then their cleanups 2 s +process.once("SIGTERM", () => { + shutdown(server, { timeout: 20_000, cleanup_timeout: 2000 }).catch( + (error: unknown) => { + process.stderr.write(`${String(error)}\n`); + process.exitCode = 1; + }, + ); +}); diff --git a/examples/multipart.ts b/examples/multipart.ts new file mode 100644 index 00000000..6db13365 --- /dev/null +++ b/examples/multipart.ts @@ -0,0 +1,108 @@ +// File uploads with explicit limits: to the disk, where the application keeps +// (`persist()`) the files it wants and the others are removed with their request, +// or streamed to a handler (here a checksum) without being kept at all. +// +// UPLOAD_DIR=/var/lib/uploads PORT=3000 node --import tsx examples/multipart.ts +// curl -F title=Report -F document=@report.pdf http://localhost:3000/documents +// curl -F file=@archive.tar http://localhost:3000/checksums +import { Binden, BindenError, type Context, Router, shutdown } from "binden"; +import { + type IFileInfo, + type IStoredForm, + type IStreamFile, + Multipart, +} from "@binden/multipart"; +import { createHash, randomUUID } from "node:crypto"; +import type { AddressInfo } from "node:net"; +import { join } from "node:path"; +import { mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; + +const MiB = 1024 * 1024; +// The limits suggested for production: the whole body, each file, and the counts +const limits = { fileSize: 5 * MiB, files: 10, fields: 100, parts: 1000 }; + +// The kept files, and the private directories of the requests in progress (on the +// same file system: `persist()` moves a file with a hard link) +const documents = process.env.UPLOAD_DIR ?? join(tmpdir(), "binden-uploads"); +const incoming = join(documents, ".incoming"); +mkdirSync(incoming, { recursive: true }); + +// What a file declares is chosen by the client: check the content too when it matters +const accepted = new Set(["application/pdf", "image/png", "text/plain"]); +const fileFilter = ({ mime_type }: IFileInfo): boolean => + accepted.has(mime_type); + +const disk = new Multipart({ + limit: 10 * MiB, + config: { limits }, + storage: { dir: incoming }, + file_filter: fileFilter, +}); + +const keep = async (context: Context): Promise => { + const { fields, files } = context.request.body as IStoredForm; + const kept = []; + for (const file of files.get("document") ?? []) { + // A name of the application: the name that the client has sent is metadata + const name = `${randomUUID()}.bin`; + await file.persist(join(documents, name)); + kept.push({ name, filename: file.filename, size: file.size }); + } + if (kept.length === 0) { + throw new BindenError(400, { expose: true, message: "No document" }); + } + await context + .status(201) + .json({ title: fields.get("title")?.[0] ?? null, kept }); +}; + +/** The checksums of the files of a request, by field (in the state of the request) */ +function checksums(context: Context): Record { + context.state.checksums ??= {}; + return context.state.checksums as Record; +} + +const stream = new Multipart({ + limit: 10 * MiB, + config: { limits }, + storage: { + on_file: async (file: IStreamFile, context: Context): Promise => { + const hash = createHash("sha256"); + for await (const chunk of file.stream) { + hash.update(chunk as Buffer); + } + if (file.truncated()) { + throw new BindenError(413); + } + checksums(context)[file.field] = hash.digest("hex"); + }, + }, +}); + +const sums = (context: Context): Promise => + context.json(checksums(context)); + +export const app = new Binden({ timeout: 30_000 }) + .use("/documents", new Router().post(disk, keep)) + .use("/checksums", new Router().post(stream, sums)); + +export const server = app.createServer({ + headersTimeout: 20_000, + requestTimeout: 120_000, + keepAliveTimeout: 65_000, +}); + +server.listen(Number(process.env.PORT ?? 3000), () => { + const { port } = server.address() as AddressInfo; + process.stdout.write(`Listening on http://localhost:${port}\n`); +}); + +process.once("SIGTERM", () => { + shutdown(server, { timeout: 20_000, cleanup_timeout: 2000 }).catch( + (error: unknown) => { + process.stderr.write(`${String(error)}\n`); + process.exitCode = 1; + }, + ); +}); diff --git a/examples/proxy.ts b/examples/proxy.ts new file mode 100644 index 00000000..5b79443e --- /dev/null +++ b/examples/proxy.ts @@ -0,0 +1,69 @@ +// Behind reverse proxies: their addresses are trusted (a `BlockList`), wherever +// they are in the chain, so that `request.ip`, `.protocol` and `.host` are those +// of the client. `PROXY_HEADERS` names the headers that the proxies send: +// `x-forwarded` (nginx, AWS ALB: `X-Forwarded-For`, `-Proto`, `-Host`, the default) +// or `forwarded` (RFC 7239). The families are never mixed. +// +// PROXY_HEADERS=x-forwarded PORT=3000 node --import tsx examples/proxy.ts +// curl -H "X-Forwarded-For: 203.0.113.7" -H "X-Forwarded-Proto: https" \ +// -H "X-Forwarded-Host: example.com" http://localhost:3000/whoami +import { type AddressInfo, BlockList, isIPv6 } from "node:net"; +import { Binden, type Context, shutdown } from "binden"; + +const headers = + process.env.PROXY_HEADERS === "forwarded" ? "forwarded" : "x-forwarded"; + +// The proxies: this host, and the private networks of the load balancers +const proxies = new BlockList(); +proxies.addAddress("127.0.0.1"); +proxies.addAddress("::1", "ipv6"); +proxies.addSubnet("10.0.0.0", 8); +proxies.addSubnet("172.16.0.0", 12); +proxies.addSubnet("192.168.0.0", 16); +proxies.addSubnet("fd00::", 8, "ipv6"); + +const whoami = (context: Context): Promise => { + const { request } = context; + return context.json({ + ip: request.ip, + ips: request.ips, + protocol: request.protocol, + host: request.host, + url: request.URL.href, + id: request.id, + }); +}; + +export const app = new Binden({ + timeout: 30_000, + trust_proxy: { + // Called synchronously for each hop, from the server outwards + trust: (address: string): boolean => + proxies.check(address, isIPv6(address) ? "ipv6" : "ipv4"), + headers, + }, + // The host that a trusted proxy has received (or `Host`) must be one of these + allowed_hosts: ["example.com", "localhost"], + // The proxies set `X-Request-Id`: a client cannot choose it + request_id: { trust_incoming: true }, +}).use("/whoami", whoami); + +export const server = app.createServer({ + headersTimeout: 20_000, + requestTimeout: 120_000, + keepAliveTimeout: 65_000, +}); + +server.listen(Number(process.env.PORT ?? 3000), () => { + const { port } = server.address() as AddressInfo; + process.stdout.write(`Listening on http://localhost:${port}\n`); +}); + +process.once("SIGTERM", () => { + shutdown(server, { timeout: 20_000, cleanup_timeout: 2000 }).catch( + (error: unknown) => { + process.stderr.write(`${String(error)}\n`); + process.exitCode = 1; + }, + ); +}); diff --git a/examples/routing.ts b/examples/routing.ts new file mode 100644 index 00000000..c7225946 --- /dev/null +++ b/examples/routing.ts @@ -0,0 +1,68 @@ +// Routes with parameters, stacks mounted under a prefix, and routers by method. +// +// PORT=3000 node --import tsx examples/routing.ts +// curl http://localhost:3000/api/v1/users/42 +// curl http://localhost:3000/files/docs/guide.md +// curl -i -X DELETE http://localhost:3000/api/v1/users +import { Binden, type Context, Router, Stack, shutdown } from "binden"; +import type { AddressInfo } from "node:net"; + +const users = new Map([ + ["1", { id: "1", name: "Ada" }], + ["2", { id: "2", name: "Grace" }], +]); + +// `/api/:version/users` and `/api/:version/users/:id`. A guarded router passes on the +// methods that it does not have: `405` (or `204` to `OPTIONS`) with `Allow` if nothing +// else handles them. +const usersStack = new Stack() + .use( + "/", + new Router({ guarded: true }) + .get((context: Context) => context.json([...users.values()])) + .post((context: Context) => context.status(201).json({ created: true })), + ) + .route( + "/:id", + new Router({ guarded: true }).get((context) => { + const user = users.get(context.params.id); + if (typeof user === "undefined") { + return context.throw(404, { expose: true, message: "No such user" }); + } + return context.json({ + user, + // The parameters of the mounts too (`version`), and the matched prefix + params: context.params, + mount_path: context.mount_path, + }); + }), + ); + +const api = new Stack().mount("/users", usersStack); + +export const app = new Binden({ timeout: 30_000 }) + .mount("/api/:version", api) + // `*path` is the rest of the path (zero or more segments), decoded once + .route("/files/*path", (context) => + context.json({ path: context.params.path }), + ); + +export const server = app.createServer({ + headersTimeout: 20_000, + requestTimeout: 120_000, + keepAliveTimeout: 65_000, +}); + +server.listen(Number(process.env.PORT ?? 3000), () => { + const { port } = server.address() as AddressInfo; + process.stdout.write(`Listening on http://localhost:${port}\n`); +}); + +process.once("SIGTERM", () => { + shutdown(server, { timeout: 20_000, cleanup_timeout: 2000 }).catch( + (error: unknown) => { + process.stderr.write(`${String(error)}\n`); + process.exitCode = 1; + }, + ); +}); diff --git a/examples/security-headers.ts b/examples/security-headers.ts new file mode 100644 index 00000000..42a8d8f1 --- /dev/null +++ b/examples/security-headers.ts @@ -0,0 +1,88 @@ +// The security headers of every response (the error replies too), set by the first +// middleware: a Content Security Policy with a nonce for the inline scripts of a +// page and `frame-ancestors` against clickjacking, `nosniff`, a referrer policy, +// COOP, and HSTS for the requests that arrived over HTTPS (here at a +// TLS-terminating proxy on this host). +// +// PORT=3000 node --import tsx examples/security-headers.ts +// curl -i http://localhost:3000/ +import { Binden, type Context, shutdown } from "binden"; +import type { AddressInfo } from "node:net"; +import { randomBytes } from "node:crypto"; + +/** The state of a request: the nonce of its inline scripts */ +interface IState { + nonce: string; +} + +const securityHeaders = (context: Context): void => { + const { nonce } = context.state; + context.response.set({ + "Content-Security-Policy": [ + "default-src 'self'", + `script-src 'nonce-${nonce}' 'strict-dynamic'`, + "object-src 'none'", + "base-uri 'none'", + "form-action 'self'", + "frame-ancestors 'none'", + ].join("; "), + "Cross-Origin-Opener-Policy": "same-origin", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + }); + // A browser ignores HSTS over plain HTTP: it is sent over HTTPS only + if (context.request.secure) { + context.setHeader( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains", + ); + } +}; + +const page = (context: Context): Promise => + context.html(` + + binden + +

+ + + +`); + +export const app = new Binden({ + timeout: 30_000, + // A new nonce for every request + state: (): IState => ({ nonce: randomBytes(16).toString("base64") }), + // The TLS-terminating proxy on this host sets `X-Forwarded-Proto` + trust_proxy: { + trust: (address: string): boolean => + address === "127.0.0.1" || address === "::1", + headers: "x-forwarded", + }, +}) + .use(securityHeaders) + .use("/", page) + .use("/api/status", (context) => context.json({ status: "ok" })); + +export const server = app.createServer({ + headersTimeout: 20_000, + requestTimeout: 120_000, + keepAliveTimeout: 65_000, +}); + +server.listen(Number(process.env.PORT ?? 3000), () => { + const { port } = server.address() as AddressInfo; + process.stdout.write(`Listening on http://localhost:${port}\n`); +}); + +process.once("SIGTERM", () => { + shutdown(server, { timeout: 20_000, cleanup_timeout: 2000 }).catch( + (error: unknown) => { + process.stderr.write(`${String(error)}\n`); + process.exitCode = 1; + }, + ); +}); diff --git a/examples/shutdown.ts b/examples/shutdown.ts new file mode 100644 index 00000000..2f815310 --- /dev/null +++ b/examples/shutdown.ts @@ -0,0 +1,58 @@ +// A graceful shutdown on SIGTERM (e.g. `docker stop`, or Kubernetes before it kills a +// pod): the server stops accepting connections, the requests in progress complete +// (within the drain time), the idle connections are closed, and the process exits once +// nothing is left. +// +// PORT=3000 node --import tsx examples/shutdown.ts +// curl -N http://localhost:3000/export & sleep 0.2; kill -TERM +import { Binden, type Context, shutdown } from "binden"; +import type { AddressInfo } from "node:net"; +import { Readable } from "node:stream"; +import { setTimeout as sleep } from "node:timers/promises"; + +/** A long response: 10 lines, one every 100 ms */ +async function* lines(signal: AbortSignal): AsyncGenerator { + for (let line = 1; line <= 10; line += 1) { + yield `line ${line}\n`; + await sleep(100, null, { signal }); + } +} + +// Once the server is closing, a request that still arrives on an open connection +// gets `503 Service Unavailable` with `Connection: close`, without running the stack +export const app = new Binden({ timeout: 30_000 }).use( + "/export", + (context: Context) => + context + .setHeader("Content-Type", "text/plain; charset=utf-8") + .send(Readable.from(lines(context.signal))), +); + +export const server = app.createServer({ + headersTimeout: 20_000, + requestTimeout: 120_000, + keepAliveTimeout: 65_000, +}); + +server.listen(Number(process.env.PORT ?? 3000), () => { + const { port } = server.address() as AddressInfo; + process.stdout.write(`Listening on http://localhost:${port}\n`); +}); + +process.once("SIGTERM", () => { + // The requests in progress have 20 s (shorter than the grace period of the + // platform); then they are aborted (`context.signal`) and their connections closed. + // The cleanups have 2 s more. + const closing = shutdown(server, { timeout: 20_000, cleanup_timeout: 2000 }); + // The server no longer accepts connections + process.stdout.write("Shutting down\n"); + closing.then( + () => { + process.stdout.write("Closed\n"); + }, + (error: unknown) => { + process.stderr.write(`${String(error)}\n`); + process.exitCode = 1; + }, + ); +}); diff --git a/examples/sse.ts b/examples/sse.ts new file mode 100644 index 00000000..618a27d0 --- /dev/null +++ b/examples/sse.ts @@ -0,0 +1,82 @@ +// Compressed responses, and a compressed stream of server-sent events: an event +// would wait in the compressor, so the stream is compressed only by an explicit +// `filter`, with a `flush()` after each complete event. The stream ends when the +// server shuts down (its clients reconnect to another instance). +// +// PORT=3000 node --import tsx examples/sse.ts +// curl --compressed http://localhost:3000/report +// curl -N --compressed http://localhost:3000/events +import { Binden, type ContentType, type Context, shutdown } from "binden"; +import { Compression, isCompressible } from "@binden/compression"; +import type { AddressInfo } from "node:net"; +import { setTimeout as sleep } from "node:timers/promises"; + +/** Aborted once the server is shutting down: the streams end */ +const closing = new AbortController(); + +// The defaults: `br`, `gzip` or `deflate` as the client accepts, for the +// compressible types and the responses of 1024 bytes or more +const compression = new Compression(); + +// The same, and the event streams +const sse = new Compression({ + filter: (_context: Context, type: ContentType | null): boolean => + type?.type === "text/event-stream" || isCompressible(type), +}); + +const report = (context: Context): Promise => + context.json( + Array.from({ length: 200 }, (_, index) => ({ + id: index + 1, + status: index % 3 === 0 ? "pending" : "done", + })), + ); + +const events = async (context: Context): Promise => { + // A stream has no deadline (`timeout` of the application) + context.setTimeout(0); + context.done = true; + const { response } = context; + const signal = AbortSignal.any([context.signal, closing.signal]); + response.setHeader("Content-Type", "text/event-stream"); + response.setHeader("Cache-Control", "no-cache"); + // nginx would buffer the stream + response.setHeader("X-Accel-Buffering", "no"); + try { + for (let id = 1; !signal.aborted; id += 1) { + const data = JSON.stringify({ time: new Date().toISOString() }); + response.write(`id: ${id}\nevent: tick\ndata: ${data}\n\n`); + // The event reaches the client now + await response.flush(); + await sleep(1000, null, { signal }); + } + } catch { + // The client has gone away, or the server is shutting down + } + response.end(); +}; + +export const app = new Binden({ timeout: 30_000 }) + .use("/report", compression, report) + .use("/events", sse, events); + +export const server = app.createServer({ + headersTimeout: 20_000, + requestTimeout: 120_000, + keepAliveTimeout: 65_000, +}); + +server.listen(Number(process.env.PORT ?? 3000), () => { + const { port } = server.address() as AddressInfo; + process.stdout.write(`Listening on http://localhost:${port}\n`); +}); + +process.once("SIGTERM", () => { + closing.abort(); + shutdown(server, { timeout: 20_000, cleanup_timeout: 2000 }).catch( + (error: unknown) => { + process.stderr.write(`${String(error)}\n`); + process.exitCode = 1; + }, + ); +}); diff --git a/examples/tsconfig.json b/examples/tsconfig.json new file mode 100644 index 00000000..015bc8b8 --- /dev/null +++ b/examples/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "declaration": false, + "noEmit": true + }, + "include": ["*.ts"] +} From 84f05ffb0f4c04fca11350c768a173f86cbd106b Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Thu, 15 Oct 2026 03:15:18 +0300 Subject: [PATCH 197/675] test: run the examples and check them over HTTP An example that no longer works, or that drifts from what the guide recommends, must fail the build rather than mislead its readers. scripts/examples.mjs starts each example on a free port, checks its statuses, headers and bodies with real requests (e.g. that each server-sent event is decompressed before the next one is sent, that a request in progress completes after SIGTERM while new connections are refused, that a spoofed X-Forwarded-For does not choose the address), then stops it with SIGTERM: it must exit with code 0 and an empty stderr. `npm run acceptance` type checks the examples and runs them from their sources against the built workspaces; the lint job of CI and ci:all run it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 + package.json | 3 +- scripts/acceptance/examples.test.mjs | 26 ++ scripts/examples.mjs | 621 +++++++++++++++++++++++++++ 4 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 scripts/acceptance/examples.test.mjs create mode 100644 scripts/examples.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed384252..540d9e18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,11 @@ jobs: - name: Tooling tests (release scripts, test runner) run: npm run test:tooling + # The examples and the process-level scenarios (shutdown, disconnects, shared + # middlewares), each in a process of its own, against the built workspaces + - name: Acceptance (examples and process-level scenarios) + run: npm run acceptance + # The next version, its release notes and its archives, made as the release job makes # them (scripts/release.mjs `verify`, `version`, `pack` and `publish --dry-run`): # nothing is tagged or published. The next checks use these archives. diff --git a/package.json b/package.json index ceddf683..f7be288f 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,10 @@ "npm": ">=11.11.0" }, "scripts": { + "acceptance": "tsc -p examples && node --test --test-timeout=120000 scripts/acceptance/*.test.mjs", "bench": "npm run build && node bench/index.mjs", "build": "npm run build --workspaces", - "ci:all": "npm ci && npm audit signatures && npm audit --omit=dev --audit-level=high && npm run release:check && npm run prettier && npm run commitlint && npm run eslint && npm run test:tooling && npm run coverage && npm run pack:check && npm run consumer:check && npm run release:rehearsal && npm run docs:build", + "ci:all": "npm ci && npm audit signatures && npm audit --omit=dev --audit-level=high && npm run release:check && npm run prettier && npm run commitlint && npm run eslint && npm run test:tooling && npm run coverage && npm run acceptance && npm run pack:check && npm run consumer:check && npm run release:rehearsal && npm run docs:build", "commitlint": "commitlint --verbose --from=origin/main", "consumer:check": "node scripts/consumer-check.mjs", "coverage": "c8 npm test --", diff --git a/scripts/acceptance/examples.test.mjs b/scripts/acceptance/examples.test.mjs new file mode 100644 index 00000000..bfd4fdb2 --- /dev/null +++ b/scripts/acceptance/examples.test.mjs @@ -0,0 +1,26 @@ +// Runs the examples (`examples/*.ts`) from their sources against the workspaces +// (built), with tsx: see `scripts/examples.mjs` for what each one must do. +import { describe, it } from "node:test"; +import { examples, runExample } from "../examples.mjs"; +import { join } from "node:path"; + +const root = join(import.meta.dirname, "..", ".."); + +describe("examples", () => { + for (const example of examples) { + it( + example.name, + { skip: process.platform === "win32" && "SIGTERM kills on Windows" }, + () => + runExample(example, { + command: process.execPath, + args: [ + "--import", + "tsx", + join(root, "examples", `${example.file}.ts`), + ], + cwd: root, + }), + ); + } +}); diff --git a/scripts/examples.mjs b/scripts/examples.mjs new file mode 100644 index 00000000..50f50580 --- /dev/null +++ b/scripts/examples.mjs @@ -0,0 +1,621 @@ +// The runnable examples (`examples/*.ts`) and what each one must do over HTTP. An +// example is a server: it is started on a free port (`PORT=0`: it prints +// `Listening on http://localhost:`), checked with real requests (statuses, +// headers and bodies), then stopped with SIGTERM, on which it must shut down +// gracefully and exit with code 0, without writing to stderr. +// +// `scripts/consumer-check.mjs` runs them compiled against the installed archives, and +// `npm run acceptance` from the sources against the workspaces (with tsx). SIGTERM is +// a hard kill on Windows: the examples are checked on POSIX systems only. +import { + brotliDecompressSync, + constants, + createGunzip, + gzipSync, +} from "node:zlib"; +import { deepEqual, equal, match, ok } from "node:assert/strict"; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import { request } from "node:http"; +import { spawn } from "node:child_process"; +import { tmpdir } from "node:os"; + +/** The time that an example has to start, to pass its checks, and to exit */ +const deadline = 30_000; + +const sleep = (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +/** Rejects after `ms` (without keeping the process alive) */ +const expire = (ms, message) => + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`Timed out: ${message}`)); + }, ms).unref(); + }); + +/** Whether a response has no such header */ +const absent = (headers, name) => !Object.hasOwn(headers, name); + +/** + * A request on a connection of its own: its status, headers and body (as received, + * e.g. compressed). `flush` sends the headers only, and settles on the response + * (e.g. a request refused by its `Content-Length` before its body is read), then + * closes the connection. + */ +function exchange(url, { method = "GET", headers = {}, body, flush = false }) { + return new Promise((resolve, reject) => { + const sent = request(url, { + method, + headers, + agent: false, + signal: AbortSignal.timeout(10_000), + }); + sent.once("error", reject).once("response", (response) => { + const chunks = []; + response + .on("data", (chunk) => { + chunks.push(chunk); + }) + .once("error", reject) + .once("end", () => { + resolve({ + status: response.statusCode, + headers: response.headers, + body: Buffer.concat(chunks), + }); + if (flush) { + sent.destroy(); + } + }); + }); + if (flush) { + sent.flushHeaders(); + } else { + sent.end(body); + } + }); +} + +/** The JSON body of a response */ +const json = ({ body }) => JSON.parse(body.toString("utf8")); + +/** A `multipart/form-data` body of a form, and its `Content-Type` */ +async function multipart(form) { + const encoded = new Request("http://localhost/", { + method: "POST", + body: form, + }); + return { + body: Buffer.from(await encoded.arrayBuffer()), + type: encoded.headers.get("content-type"), + }; +} + +/** Polls `condition` until it holds, or fails after `ms` */ +async function eventually(condition, ms, message) { + const end = Date.now() + ms; + while (!condition()) { + if (Date.now() > end) { + throw new Error(`Timed out: ${message}`); + } + await sleep(20); + } +} + +async function hello({ url }) { + const response = await exchange(`${url}/`, {}); + equal(response.status, 200); + equal(response.headers["content-type"], "text/plain; charset=utf-8"); + equal(response.body.toString(), "Hello, World!"); + equal((await exchange(`${url}/missing`, {})).status, 404); +} + +async function routing({ url }) { + const list = await exchange(`${url}/api/v1/users`, {}); + equal(list.status, 200); + deepEqual(json(list), [ + { id: "1", name: "Ada" }, + { id: "2", name: "Grace" }, + ]); + deepEqual(json(await exchange(`${url}/api/v2/users/2`, {})), { + user: { id: "2", name: "Grace" }, + params: { version: "v2", id: "2" }, + mount_path: "/api/v2/users", + }); + const missing = await exchange(`${url}/api/v1/users/3`, {}); + equal(missing.status, 404); + equal(missing.body.toString(), "No such user"); + equal( + (await exchange(`${url}/api/v1/users`, { method: "POST" })).status, + 201, + ); + // The methods of the guarded routers + const refused = await exchange(`${url}/api/v1/users`, { method: "DELETE" }); + equal(refused.status, 405); + equal(refused.headers.allow, "GET, POST, HEAD, OPTIONS"); + const options = await exchange(`${url}/api/v1/users/1`, { + method: "OPTIONS", + }); + equal(options.status, 204); + equal(options.headers.allow, "GET, HEAD, OPTIONS"); + // `*path`, decoded once; an encoded slash is refused + deepEqual(json(await exchange(`${url}/files/docs/a%20guide.md`, {})), { + path: "docs/a guide.md", + }); + equal((await exchange(`${url}/files/a%2Fb`, {})).status, 400); + equal((await exchange(`${url}/api`, {})).status, 404); +} + +async function bodyCheck({ url }) { + const item = { name: "tea", quantity: 2 }; + const created = await exchange(`${url}/items`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(item), + }); + equal(created.status, 201); + deepEqual(json(created), item); + const compressed = await exchange(`${url}/items`, { + method: "POST", + headers: { "Content-Type": "application/json", "Content-Encoding": "gzip" }, + body: gzipSync(JSON.stringify({ name: "coffee" })), + }); + equal(compressed.status, 201); + deepEqual(json(compressed), { name: "coffee", quantity: 1 }); + const invalid = await exchange(`${url}/items`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{", + }); + equal(invalid.status, 400); + const unprocessable = await exchange(`${url}/items`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "[]", + }); + equal(unprocessable.status, 422); + // Another media type is not read by the parser: the route refuses it + const xml = await exchange(`${url}/items`, { + method: "POST", + headers: { "Content-Type": "application/xml" }, + body: "", + }); + equal(xml.status, 415); + equal(xml.body.toString(), "Send application/json"); + // Refused by its length before its body is read, and the connection closed + const large = await exchange(`${url}/items`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": 1024 * 1024 + 1, + }, + flush: true, + }); + equal(large.status, 413); + equal(large.headers.connection, "close"); +} + +async function multipartCheck({ url, dir }) { + const form = new FormData(); + form.append("title", "Reports"); + form.append( + "document", + new File(["%PDF-1.7 first"], "first.pdf", { type: "application/pdf" }), + ); + form.append( + "document", + new File(["second"], "../second.txt", { type: "text/plain" }), + ); + const upload = await multipart(form); + const created = await exchange(`${url}/documents`, { + method: "POST", + headers: { "Content-Type": upload.type }, + body: upload.body, + }); + equal(created.status, 201); + const { title, kept } = json(created); + equal(title, "Reports"); + deepEqual( + kept.map(({ filename, size }) => ({ filename, size })), + [ + { filename: "first.pdf", size: 14 }, + // The name that the client has sent, without its path (metadata only) + { filename: "second.txt", size: 6 }, + ], + ); + // The kept files are those of the application, the others are removed + deepEqual( + kept.map(({ name }) => readFileSync(join(dir, name), "utf8")), + ["%PDF-1.7 first", "second"], + ); + await eventually( + () => readdirSync(join(dir, ".incoming")).length === 0, + 5000, + "the private directory of the request is removed", + ); + + // A type that `file_filter` refuses + const refused = new FormData(); + refused.append( + "document", + new File(["MZ"], "setup.exe", { type: "application/x-msdownload" }), + ); + const exe = await multipart(refused); + const unsupported = await exchange(`${url}/documents`, { + method: "POST", + headers: { "Content-Type": exe.type }, + body: exe.body, + }); + equal(unsupported.status, 415); + // A body over `limit`, refused by its length + const large = await exchange(`${url}/documents`, { + method: "POST", + headers: { + "Content-Type": exe.type, + "Content-Length": 10 * 1024 * 1024 + 1, + }, + flush: true, + }); + equal(large.status, 413); + equal(large.headers.connection, "close"); + await eventually( + () => readdirSync(join(dir, ".incoming")).length === 0, + 5000, + "the private directories of the refused requests are removed", + ); + + // Streamed to a checksum, never stored + const content = "x".repeat(256 * 1024); + const streamed = new FormData(); + streamed.append("archive", new File([content], "archive.tar")); + const archive = await multipart(streamed); + const sums = await exchange(`${url}/checksums`, { + method: "POST", + headers: { "Content-Type": archive.type }, + body: archive.body, + }); + equal(sums.status, 200); + deepEqual(json(sums), { + archive: createHash("sha256").update(content).digest("hex"), + }); +} + +/** + * Reads the events of a compressed stream as they come: resolves with the first + * `count` (they are decompressed, so each of them has been flushed), then waits + * for the end of the stream + */ +function readEvents(url, count) { + return new Promise((resolve, reject) => { + const sent = request(url, { + agent: false, + headers: { "Accept-Encoding": "gzip" }, + }); + sent.once("error", reject).once("response", (response) => { + const { statusCode, headers } = response; + let text = ""; + const ended = new Promise((done, fail) => { + response.once("end", done).once("error", fail); + }); + const gunzip = createGunzip({ flush: constants.Z_SYNC_FLUSH }); + gunzip.setEncoding("utf8").on("data", (chunk) => { + text += chunk; + const events = text.split("\n\n").slice(0, -1); + if (events.length >= count) { + resolve({ status: statusCode, headers, events, ended }); + } + }); + response.on("data", (chunk) => { + gunzip.write(chunk); + }); + }); + sent.end(); + }); +} + +async function sse({ url, stop }) { + const report = await exchange(`${url}/report`, { + headers: { "Accept-Encoding": "br, gzip" }, + }); + equal(report.status, 200); + equal(report.headers["content-encoding"], "br"); + equal(report.headers.vary, "Accept-Encoding"); + ok(absent(report.headers, "content-length")); + const rows = JSON.parse(brotliDecompressSync(report.body).toString()); + equal(rows.length, 200); + // Without `Accept-Encoding`: identity + const identity = await exchange(`${url}/report`, {}); + ok(absent(identity.headers, "content-encoding")); + equal(Number(identity.headers["content-length"]), identity.body.length); + + // Each event is decompressed as soon as it is sent, a second apart + const stream = await Promise.race([ + readEvents(`${url}/events`, 2), + expire(5000, "the events have not been flushed"), + ]); + equal(stream.status, 200); + equal(stream.headers["content-type"], "text/event-stream"); + equal(stream.headers["content-encoding"], "gzip"); + equal(stream.headers["x-accel-buffering"], "no"); + deepEqual( + stream.events.map((event) => event.split("\n").slice(0, 2)), + [ + ["id: 1", "event: tick"], + ["id: 2", "event: tick"], + ], + ); + // The stream ends when the server shuts down + const exit = stop(); + await stream.ended; + return exit; +} + +async function shutdownCheck({ url, stop, output }) { + const { promise: started, resolve: start } = Promise.withResolvers(); + const sent = request(`${url}/export`, { agent: false }); + const completed = new Promise((resolve, reject) => { + sent.once("error", reject).once("response", (response) => { + start(); + let text = ""; + response + .setEncoding("utf8") + .on("data", (chunk) => { + text += chunk; + }) + .once("error", reject) + .once("end", () => { + resolve({ status: response.statusCode, text }); + }); + }); + }); + sent.end(); + // The response has started: SIGTERM while it is in progress + await Promise.race([started, completed]); + const exit = stop(); + await eventually( + () => output().includes("Shutting down\n"), + 5000, + "the shutdown has started", + ); + // No new connection is accepted + await exchange(`${url}/export`, {}).then( + () => { + throw new Error("A connection has been accepted while closing"); + }, + (error) => { + equal(error.code, "ECONNREFUSED"); + }, + ); + const { status, text } = await completed; + equal(status, 200); + equal( + text, + Array.from({ length: 10 }, (_, index) => `line ${index + 1}\n`).join(""), + ); + await exit; + match(output(), /Shutting down\nClosed\n$/u); + return exit; +} + +/** The `whoami` of the proxy example, with the `Host` of a local proxy */ +async function whoami(url, headers) { + const { port } = new URL(url); + const response = await exchange(`${url}/whoami`, { + headers: { Host: `localhost:${port}`, ...headers }, + }); + return { status: response.status, response }; +} + +async function xForwarded({ url }) { + const { status, response } = await whoami(url, { + "X-Forwarded-For": "203.0.113.7, 10.1.2.3", + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "example.com", + "X-Request-Id": "req-1", + }); + equal(status, 200); + equal(response.headers["x-request-id"], "req-1"); + deepEqual(json(response), { + ip: "203.0.113.7", + ips: ["203.0.113.7", "10.1.2.3", "127.0.0.1"], + protocol: "https:", + host: "example.com", + url: "https://example.com/whoami", + id: "req-1", + }); + // The client is the first address that is not a proxy: the addresses on its + // left were sent by the client + const spoofed = await whoami(url, { + "X-Forwarded-For": "10.9.9.9, 198.51.100.4, 10.1.2.3", + }); + deepEqual(json(spoofed.response).ips, [ + "198.51.100.4", + "10.1.2.3", + "127.0.0.1", + ]); + // The other family is ignored + const other = await whoami(url, { Forwarded: "for=203.0.113.9" }); + equal(json(other.response).ip, "127.0.0.1"); + // A host that is not allowed + const host = await whoami(url, { "X-Forwarded-Host": "evil.example" }); + equal(host.status, 400); +} + +async function forwarded({ url }) { + const { status, response } = await whoami(url, { + Forwarded: + "for=203.0.113.7;proto=https;host=example.com, for=10.1.2.3;proto=http", + }); + equal(status, 200); + const { id, ...rest } = json(response); + equal(response.headers["x-request-id"], id); + // `proto` and `host` of the element of the farthest trusted proxy + deepEqual(rest, { + ip: "203.0.113.7", + ips: ["203.0.113.7", "10.1.2.3", "127.0.0.1"], + protocol: "https:", + host: "example.com", + url: "https://example.com/whoami", + }); + const ipv6 = await whoami(url, { + Forwarded: 'for="[2001:db8::1]:4711";proto=https;host=example.com', + }); + equal(json(ipv6.response).ip, "2001:db8::1"); + const other = await whoami(url, { "X-Forwarded-For": "203.0.113.9" }); + equal(json(other.response).ip, "127.0.0.1"); + const host = await whoami(url, { + Forwarded: "for=198.51.100.4;host=evil.example", + }); + equal(host.status, 400); +} + +async function securityHeaders({ url }) { + const page = await exchange(`${url}/`, {}); + equal(page.status, 200); + const { headers } = page; + const [, nonce] = + /script-src 'nonce-(?[A-Za-z0-9+/=]{24})' 'strict-dynamic'/u.exec( + headers["content-security-policy"], + ) ?? []; + ok(nonce, headers["content-security-policy"]); + match(headers["content-security-policy"], /frame-ancestors 'none'/u); + ok(page.body.toString().includes(`